From 74e53f1830076b3a373346c5ad8676630b3ab66c Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 21 Sep 2026 00:44:25 +0800 Subject: [PATCH 01/11] Talk to the real NEAR Intents 1Click API for swaps SwapService is now a 1Click client: token list, dry quote, live quote with a deposit address, and status by deposit address. The mock quotes, fake deposit address, hard-coded prices and the silent token fallback are gone. Server rejections surface as SwapApiException with the server's message. The HTTP client and partner API key are injectable. SwapToken carries the 1Click asset id and USD price. SwapQuote and SwapOrder hold base-unit amounts as BigInt. NumberFormattingService takes a decimals count so every token reuses the locale-aware parser and formatter. QTC is not listed on NEAR Intents yet, so quantusIntentsAssetId is a placeholder and every quote into it fails with "tokenOut is not valid". Tests cover the request shape, response parsing, all statuses and the error paths against a mock client. --- quantus_sdk/lib/quantus_sdk.dart | 3 + .../lib/src/constants/app_constants.dart | 7 + quantus_sdk/lib/src/models/swap_order.dart | 58 +++ quantus_sdk/lib/src/models/swap_quote.dart | 62 +++ quantus_sdk/lib/src/models/swap_token.dart | 35 ++ .../services/number_formatting_service.dart | 22 +- .../lib/src/services/swap_service.dart | 451 ++++++------------ .../test/services/swap_service_test.dart | 334 +++++++++++++ 8 files changed, 666 insertions(+), 306 deletions(-) create mode 100644 quantus_sdk/lib/src/models/swap_order.dart create mode 100644 quantus_sdk/lib/src/models/swap_quote.dart create mode 100644 quantus_sdk/lib/src/models/swap_token.dart create mode 100644 quantus_sdk/test/services/swap_service_test.dart diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 0d296a99a..f63622630 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -78,6 +78,9 @@ export 'src/services/settings_service.dart'; export 'src/services/substrate_service.dart'; export 'src/services/transaction_fee.dart'; export 'src/services/swap_service.dart'; +export 'src/models/swap_token.dart'; +export 'src/models/swap_quote.dart'; +export 'src/models/swap_order.dart'; export 'src/services/quersi_service.dart'; export 'src/services/senoti_service.dart'; export 'src/services/circuit_manager.dart'; diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index ecb5386eb..c5e0246fc 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -23,6 +23,13 @@ class AppConstants { static const String explorerEndpoint = 'https://explorer.quantus.com'; + // NEAR Intents 1Click swap API (docs.near-intents.org). + static const String oneClickEndpoint = 'https://1click.chaindefuser.com'; + + // Quantus is not listed on NEAR Intents yet, so every quote into it is + // rejected as "tokenOut is not valid". Replace with the listed asset id. + static const String quantusIntentsAssetId = 'nep141:qtc.omft.near'; + // internal group URL is this (note the /c) // https://t.me/c/quantusnetwork/2457 // removing the c, we get a better preview page though so we use it without c... diff --git a/quantus_sdk/lib/src/models/swap_order.dart b/quantus_sdk/lib/src/models/swap_order.dart new file mode 100644 index 000000000..95d51e448 --- /dev/null +++ b/quantus_sdk/lib/src/models/swap_order.dart @@ -0,0 +1,58 @@ +import 'package:quantus_sdk/src/models/swap_quote.dart'; + +enum SwapStatus { + pendingDeposit('PENDING_DEPOSIT'), + knownDepositTx('KNOWN_DEPOSIT_TX'), + incompleteDeposit('INCOMPLETE_DEPOSIT'), + processing('PROCESSING'), + success('SUCCESS'), + refunded('REFUNDED'), + failed('FAILED'); + + const SwapStatus(this.wire); + final String wire; + + bool get isFinal => this == success || this == refunded || this == failed; + + static SwapStatus parse(String wire) => + values.firstWhere((s) => s.wire == wire, orElse: () => throw FormatException('Unknown swap status: $wire')); +} + +/// A live quote plus what 1Click has reported about it so far. +class SwapOrder { + final SwapQuote quote; + final SwapStatus status; + final BigInt? amountOut; + final BigInt? refundedAmount; + final String? refundReason; + final List destinationTxHashes; + + const SwapOrder({ + required this.quote, + required this.status, + this.amountOut, + this.refundedAmount, + this.refundReason, + this.destinationTxHashes = const [], + }); + + String get depositAddress => quote.depositAddress!; + + factory SwapOrder.fromStatusJson(Map json, {required SwapQuote quote}) { + final details = json['swapDetails'] as Map?; + BigInt? amount(String key) { + final raw = details?[key] as String?; + return raw == null || raw.isEmpty ? null : BigInt.parse(raw); + } + + final destination = details?['destinationChainTxHashes'] as List? ?? const []; + return SwapOrder( + quote: quote, + status: SwapStatus.parse(json['status'] as String), + amountOut: amount('amountOut'), + refundedAmount: amount('refundedAmount'), + refundReason: details?['refundReason'] as String?, + destinationTxHashes: [for (final tx in destination) (tx as Map)['hash'] as String], + ); + } +} diff --git a/quantus_sdk/lib/src/models/swap_quote.dart b/quantus_sdk/lib/src/models/swap_quote.dart new file mode 100644 index 000000000..24edd4223 --- /dev/null +++ b/quantus_sdk/lib/src/models/swap_quote.dart @@ -0,0 +1,62 @@ +import 'package:quantus_sdk/src/models/swap_token.dart'; + +/// A 1Click quote. Amounts are base units of their token. [depositAddress] is +/// only set on a live (non-dry) quote; funds sent there before [deadline] are +/// swapped, anything else is refunded to [refundAddress]. +class SwapQuote { + final SwapToken fromToken; + final SwapToken toToken; + final BigInt amountIn; + final BigInt amountOut; + final BigInt minAmountOut; + final double amountInUsd; + final double amountOutUsd; + final int slippageBps; + final String refundAddress; + final String recipient; + final DateTime deadline; + final Duration timeEstimate; + final String correlationId; + final String? depositAddress; + final String? depositMemo; + + const SwapQuote({ + required this.fromToken, + required this.toToken, + required this.amountIn, + required this.amountOut, + required this.minAmountOut, + required this.amountInUsd, + required this.amountOutUsd, + required this.slippageBps, + required this.refundAddress, + required this.recipient, + required this.deadline, + required this.timeEstimate, + required this.correlationId, + this.depositAddress, + this.depositMemo, + }); + + factory SwapQuote.fromJson(Map json, {required SwapToken fromToken, required SwapToken toToken}) { + final quote = json['quote'] as Map; + final request = json['quoteRequest'] as Map; + return SwapQuote( + fromToken: fromToken, + toToken: toToken, + amountIn: BigInt.parse(quote['amountIn'] as String), + amountOut: BigInt.parse(quote['amountOut'] as String), + minAmountOut: BigInt.parse(quote['minAmountOut'] as String), + amountInUsd: double.parse(quote['amountInUsd'] as String), + amountOutUsd: double.parse(quote['amountOutUsd'] as String), + slippageBps: (request['slippageTolerance'] as num).toInt(), + refundAddress: request['refundTo'] as String, + recipient: request['recipient'] as String, + deadline: DateTime.parse((quote['deadline'] ?? request['deadline']) as String), + timeEstimate: Duration(seconds: (quote['timeEstimate'] as num).round()), + correlationId: json['correlationId'] as String, + depositAddress: quote['depositAddress'] as String?, + depositMemo: quote['depositMemo'] as String?, + ); + } +} diff --git a/quantus_sdk/lib/src/models/swap_token.dart b/quantus_sdk/lib/src/models/swap_token.dart new file mode 100644 index 000000000..284f11f40 --- /dev/null +++ b/quantus_sdk/lib/src/models/swap_token.dart @@ -0,0 +1,35 @@ +class SwapToken { + final String assetId; + final String symbol; + final String network; + final int decimals; + final double usdPrice; + final String? iconUrl; + final String? networkIconUrl; + + const SwapToken({ + required this.assetId, + required this.symbol, + required this.network, + required this.decimals, + required this.usdPrice, + this.iconUrl, + this.networkIconUrl, + }); + + SwapToken copyWith({String? iconUrl, String? networkIconUrl}) => SwapToken( + assetId: assetId, + symbol: symbol, + network: network, + decimals: decimals, + usdPrice: usdPrice, + iconUrl: iconUrl ?? this.iconUrl, + networkIconUrl: networkIconUrl ?? this.networkIconUrl, + ); + + @override + bool operator ==(Object other) => other is SwapToken && assetId == other.assetId; + + @override + int get hashCode => assetId.hashCode; +} diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart index ef1d76dba..398a05157 100644 --- a/quantus_sdk/lib/src/services/number_formatting_service.dart +++ b/quantus_sdk/lib/src/services/number_formatting_service.dart @@ -7,6 +7,10 @@ class NumberFormattingService { static final BigInt scaleFactorBigInt = BigInt.from(10).pow(decimals); static final Decimal scaleFactorDecimal = Decimal.fromBigInt(scaleFactorBigInt); + static Decimal _scale(int decimals) => decimals == NumberFormattingService.decimals + ? scaleFactorDecimal + : Decimal.fromBigInt(BigInt.from(10).pow(decimals)); + final LocaleNumberConfig _localeConfig; NumberFormattingService({LocaleNumberConfig? localeConfig}) @@ -24,8 +28,9 @@ class NumberFormattingService { /// Example: 1234500000000 -> "1.2345" (smartDecimals = 4, US locale) /// Example: 100000000 -> "0.0001" (smartDecimals = 2, extended) /// Standard token-amount display: 4 smart decimals, extending to the - /// chain's full precision for smaller amounts. - String formatAmount(BigInt amount) => formatBalance(amount, smartDecimals: 4, maxDecimals: AppConstants.decimals); + /// token's full precision for smaller amounts. + String formatAmount(BigInt amount, {int decimals = AppConstants.decimals}) => + formatBalance(amount, decimals: decimals, smartDecimals: 4, maxDecimals: decimals); /// A whole count with the locale's grouping separators, e.g. 5279 -> "5,279". String formatInteger(int value) => _localeConfig.localize(value.toString()); @@ -38,6 +43,7 @@ class NumberFormattingService { String formatBalance( BigInt balance, { + int decimals = AppConstants.decimals, int smartDecimals = 4, int maxDecimals = 6, bool addThousandsSeparators = true, @@ -49,7 +55,7 @@ class NumberFormattingService { return addSymbol ? '$resultString ${AppConstants.tokenSymbol}' : resultString; } - final decimalBalance = (Decimal.fromBigInt(balance) / scaleFactorDecimal).toDecimal( + final decimalBalance = (Decimal.fromBigInt(balance) / _scale(decimals)).toDecimal( scaleOnInfinitePrecision: decimals, ); @@ -98,10 +104,10 @@ class NumberFormattingService { /// Formats a balance for payment URL wire transport: dot decimal, no grouping. /// /// Wire amounts are locale-neutral and must be parsed with [parseWireAmount]. - String formatWireAmount(BigInt balance) { + String formatWireAmount(BigInt balance, {int decimals = AppConstants.decimals}) { return NumberFormattingService( localeConfig: LocaleNumberConfig.dotDecimal, - ).formatBalance(balance, smartDecimals: decimals, addThousandsSeparators: false); + ).formatBalance(balance, decimals: decimals, smartDecimals: decimals, addThousandsSeparators: false); } /// Wire amount shape: NNN[.NNN], dot as the decimal separator, no grouping, @@ -131,12 +137,12 @@ class NumberFormattingService { } /// Parses a user-entered formatted string amount into a raw BigInt amount - /// scaled by the chain's decimals. + /// scaled by [decimals], the chain's by default. /// /// The input is interpreted using the [LocaleNumberConfig] supplied at /// construction (decimal/grouping separators come from the user's locale). /// Returns [BigInt.zero] for an empty string and `null` for unparseable input. - BigInt? parseAmount(String formattedAmount) { + BigInt? parseAmount(String formattedAmount, {int decimals = AppConstants.decimals}) { if (formattedAmount.isEmpty) { return BigInt.zero; } @@ -146,7 +152,7 @@ class NumberFormattingService { if (decimalAmount.scale > decimals) { quantusPrint('Warning: Input amount $formattedAmount exceeds $decimals decimals, will be truncated.'); } - final rawDecimalAmount = decimalAmount * scaleFactorDecimal; + final rawDecimalAmount = decimalAmount * _scale(decimals); return rawDecimalAmount.toBigInt(); } catch (e) { quantusPrint('Error parsing amount $formattedAmount: $e'); diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index 50189d552..456f12eb3 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -1,272 +1,209 @@ import 'dart:convert'; -import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:http/http.dart' as http; import 'package:quantus_sdk/src/constants/app_constants.dart'; +import 'package:quantus_sdk/src/models/swap_order.dart'; +import 'package:quantus_sdk/src/models/swap_quote.dart'; +import 'package:quantus_sdk/src/models/swap_token.dart'; +import 'package:quantus_sdk/src/utils/print.dart'; import 'package:shared_preferences/shared_preferences.dart'; -enum SwapStatus { pending, depositing, processing, complete, failed, expired } +/// 1Click answered [statusCode] with [message]. +class SwapApiException implements Exception { + final int statusCode; + final String message; -class SwapToken { - final String symbol; - final String name; - final String network; - final int decimals; - final String? iconUrl; - final String? networkIconUrl; - - const SwapToken({ - required this.symbol, - required this.name, - required this.network, - this.decimals = 18, - this.iconUrl, - this.networkIconUrl, - }); - - @override - bool operator ==(Object other) => other is SwapToken && symbol == other.symbol && network == other.network; + const SwapApiException(this.statusCode, this.message); @override - int get hashCode => Object.hash(symbol, network); -} - -class SwapQuote { - final String quoteId; - final SwapToken fromToken; - final SwapToken toToken; - final double fromAmount; - final double toAmount; - final double rate; - final double networkFee; - final double totalAmount; - final double slippageTolerance; - final Duration expiresIn; - - const SwapQuote({ - required this.quoteId, - required this.fromToken, - required this.toToken, - required this.fromAmount, - required this.toAmount, - required this.rate, - required this.networkFee, - required this.totalAmount, - required this.slippageTolerance, - required this.expiresIn, - }); -} - -class SwapOrder { - final String orderId; - final SwapQuote quote; - final String depositAddress; - final SwapStatus status; - final DateTime createdAt; - - const SwapOrder({ - required this.orderId, - required this.quote, - required this.depositAddress, - required this.status, - required this.createdAt, - }); - - SwapOrder copyWith({SwapStatus? status}) => SwapOrder( - orderId: orderId, - quote: quote, - depositAddress: depositAddress, - status: status ?? this.status, - createdAt: createdAt, - ); + String toString() => 'SwapApiException($statusCode): $message'; } +/// Client for the NEAR Intents 1Click API. A quote names a deposit address on +/// the origin chain; whatever the user sends there before the deadline is +/// swapped by solvers and paid out to the recipient, or refunded. Nothing in +/// the app ever holds or signs the swapped funds. class SwapService { - static final SwapService _instance = SwapService._(); - factory SwapService() => _instance; - SwapService._(); - + static const defaultSlippageBps = 100; + static const depositWindow = Duration(minutes: 20); + static const quoteWaitingTime = Duration(seconds: 3); + static const statusPollInterval = Duration(seconds: 5); static const _refundAddressKey = 'recent_refund_addresses'; static const _maxRefundAddresses = 50; - static const _intentsTokensUrl = 'https://1click.chaindefuser.com/v0/tokens'; static const _coinGeckoTopUrl = 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=150&page=1&sparkline=false'; static const _tokensCacheTtl = Duration(minutes: 10); - final _orders = {}; + + final http.Client _client; + final Uri _base; + final String? _apiKey; List? _cachedFromTokens; DateTime? _cachedFromTokensAt; - Map _liveUsdPriceBySymbol = {}; - - static const availableTokens = [ - SwapToken(symbol: 'USDC', name: 'USD Coin', network: 'Ethereum'), - SwapToken(symbol: 'USDT', name: 'Tether', network: 'Ethereum'), - SwapToken(symbol: 'ETH', name: 'Ethereum', network: 'Ethereum'), - SwapToken(symbol: 'BTC', name: 'Bitcoin', network: 'Bitcoin', decimals: 8), - SwapToken(symbol: 'SOL', name: 'Solana', network: 'Solana', decimals: 9), - _quToken, - ]; - static const _quToken = SwapToken(symbol: AppConstants.tokenSymbol, name: 'Quantus', network: 'Quantus'); + SwapService({http.Client? client, String endpoint = AppConstants.oneClickEndpoint, String? apiKey}) + : _client = client ?? http.Client(), + _base = Uri.parse(endpoint), + _apiKey = apiKey; + + static SwapToken quantusToken({required double usdPrice}) => SwapToken( + assetId: AppConstants.quantusIntentsAssetId, + symbol: AppConstants.tokenSymbol, + network: 'Quantus', + decimals: AppConstants.decimals, + usdPrice: usdPrice, + ); Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { final now = DateTime.now(); - if (!forceRefresh && - _cachedFromTokens != null && - _cachedFromTokensAt != null && - now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) { - return _cachedFromTokens!.take(limit).toList(); + final cached = _cachedFromTokens; + if (!forceRefresh && cached != null && now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) { + return cached.take(limit).toList(); } - - try { - final intentTokens = await _fetchNearIntentsTokens(); - if (intentTokens.isNotEmpty) { - final ranked = await _rankByCoinGecko(intentTokens); - _cachedFromTokens = ranked; - _cachedFromTokensAt = now; - _liveUsdPriceBySymbol = {for (final token in ranked) token.symbol.toUpperCase(): token.price}; - return ranked.take(limit).toList(); - } - } catch (_) {} - - final fallback = availableTokens.where((t) => t.symbol != AppConstants.tokenSymbol).take(limit).toList(); - _cachedFromTokens = fallback; + final tokens = await _rankByCoinGecko(await _fetchIntentsTokens()); + _cachedFromTokens = tokens; _cachedFromTokensAt = now; - return fallback; - } - - SwapToken getQuToken() => _quToken; - - static String formatTokenAmount(double amount, SwapToken token) { - if (amount == 0) return '0'; - final decimals = token.decimals.clamp(0, 12); - var s = amount.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); - if (s.endsWith('.')) s = s.substring(0, s.length - 1); - return s; - } - - static String formatTokenAmountHint(SwapToken token) { - if (token.decimals == 0) return '0'; - return '0.${'0' * token.decimals.clamp(1, 8)}'; + return tokens.take(limit).toList(); } - String? getTokenIconUrl(SwapToken token) { - final cached = _cachedFromTokens?.where((t) => t.symbol == token.symbol && t.network == token.network).firstOrNull; - if (cached?.iconUrl != null && cached!.iconUrl!.isNotEmpty) return cached.iconUrl; - return _fallbackTokenIconUrl(token.symbol); + /// Asks solvers for a price on [amount] base units of [from]. A dry quote is + /// a preview; a live one reserves a deposit address until its deadline. + Future getQuote({ + required SwapToken from, + required SwapToken to, + required BigInt amount, + required String refundAddress, + required String recipient, + int slippageBps = defaultSlippageBps, + bool dry = true, + }) async { + final json = await _send( + 'POST', + '/v0/quote', + body: { + 'dry': dry, + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': slippageBps, + 'originAsset': from.assetId, + 'depositType': 'ORIGIN_CHAIN', + 'destinationAsset': to.assetId, + 'amount': amount.toString(), + 'refundTo': refundAddress, + 'refundType': 'ORIGIN_CHAIN', + 'recipient': recipient, + 'recipientType': 'DESTINATION_CHAIN', + 'deadline': DateTime.now().toUtc().add(depositWindow).toIso8601String(), + 'quoteWaitingTimeMs': quoteWaitingTime.inMilliseconds, + }, + ); + return SwapQuote.fromJson(json as Map, fromToken: from, toToken: to); } - String? getNetworkIconUrl(SwapToken token) { - final cached = _cachedFromTokens?.where((t) => t.symbol == token.symbol && t.network == token.network).firstOrNull; - if (cached?.networkIconUrl != null && cached!.networkIconUrl!.isNotEmpty) return cached.networkIconUrl; - return _networkIconUrl(token.network); + /// Re-quotes [quote] live so 1Click reserves a deposit address for it. + Future createSwap(SwapQuote quote) async { + final live = await getQuote( + from: quote.fromToken, + to: quote.toToken, + amount: quote.amountIn, + refundAddress: quote.refundAddress, + recipient: quote.recipient, + slippageBps: quote.slippageBps, + dry: false, + ); + if (live.depositAddress == null) throw StateError('Live quote ${live.correlationId} has no deposit address'); + return SwapOrder(quote: live, status: SwapStatus.pendingDeposit); } - double getRate(SwapToken from) { - final fromUsd = getUsdPrice(from); - final toUsd = getUsdPrice(_quToken); - if (fromUsd <= 0 || toUsd <= 0) return 1.0; - return fromUsd / toUsd; + Future getSwapStatus(SwapOrder order) async { + final memo = order.quote.depositMemo; + final json = await _send( + 'GET', + '/v0/status', + query: {'depositAddress': order.depositAddress, 'depositMemo': ?memo}, + ); + return SwapOrder.fromStatusJson(json as Map, quote: order.quote); } - double getUsdPrice(SwapToken token) { - final livePrice = _liveUsdPriceBySymbol[token.symbol.toUpperCase()]; - if (livePrice != null && livePrice > 0) return livePrice; - switch (token.symbol.toUpperCase()) { - case 'USDC': - case 'USDT': - return 1.0; - case 'ETH': - return 2500.0; - case 'BTC': - return 60000.0; - case 'SOL': - return 150.0; - case AppConstants.tokenSymbol: - return 1.0; - default: - return 0.0; + Future _send(String method, String path, {Map? body, Map? query}) async { + final uri = _base.replace(path: path, queryParameters: query); + final headers = {'Content-Type': 'application/json', 'X-API-Key': ?_apiKey}; + final response = method == 'GET' + ? await _client.get(uri, headers: headers) + : await _client.post(uri, headers: headers, body: jsonEncode(body)); + final ok = response.statusCode >= 200 && response.statusCode < 300; + Object? json; + try { + json = jsonDecode(response.body); + } on FormatException { + if (ok) rethrow; } - } - - Future> _fetchNearIntentsTokens() async { - final response = await http.get(Uri.parse(_intentsTokensUrl)); - if (response.statusCode < 200 || response.statusCode >= 300) { - throw Exception('Failed to fetch intents tokens'); + if (!ok) { + final message = json is Map ? json['message'] : null; + throw SwapApiException(response.statusCode, switch (message) { + String s => s, + List l => l.join(', '), + _ => response.body, + }); } - final data = jsonDecode(response.body); - if (data is! List) return const []; + return json; + } - final bySymbol = {}; - for (final item in data) { - if (item is! Map) continue; - final symbolRaw = item['symbol']; - final blockchainRaw = item['blockchain']; - final decimalsRaw = item['decimals']; - final priceRaw = item['price']; - if (symbolRaw is! String || symbolRaw.isEmpty) continue; - if (blockchainRaw is! String || blockchainRaw.isEmpty) continue; - final symbol = symbolRaw.toUpperCase(); - if (symbol == AppConstants.tokenSymbol) continue; - final price = (priceRaw as num?)?.toDouble() ?? 0; + Future> _fetchIntentsTokens() async { + final data = await _send('GET', '/v0/tokens') as List; + final bySymbol = {}; + for (final item in data.cast>()) { + final price = (item['price'] as num?)?.toDouble() ?? 0; if (price <= 0) continue; - final decimals = (decimalsRaw as num?)?.toInt() ?? 18; - final token = _IntentToken( - symbol: symbol, - network: blockchainRaw.toUpperCase(), - decimals: decimals, - price: price, - networkIconUrl: _networkIconUrl(blockchainRaw.toUpperCase()), + final network = (item['blockchain'] as String).toUpperCase(); + final token = SwapToken( + assetId: item['assetId'] as String, + symbol: (item['symbol'] as String).toUpperCase(), + network: network, + decimals: (item['decimals'] as num).toInt(), + usdPrice: price, + networkIconUrl: _networkIconUrl(network), ); - final existing = bySymbol[symbol]; + if (token.symbol == AppConstants.tokenSymbol) continue; + final existing = bySymbol[token.symbol]; if (existing == null || _networkPriority(token.network) < _networkPriority(existing.network)) { - bySymbol[symbol] = token; + bySymbol[token.symbol] = token; } } return bySymbol.values.toList(); } - Future> _rankByCoinGecko(List<_IntentToken> tokens) async { + /// Orders [tokens] by CoinGecko market cap and picks up their icons. A + /// CoinGecko failure only costs the ordering, so it is logged, not thrown. + Future> _rankByCoinGecko(List tokens) async { + final rankBySymbol = {}; + final iconBySymbol = {}; try { - final response = await http.get(Uri.parse(_coinGeckoTopUrl)); + final response = await _client.get(Uri.parse(_coinGeckoTopUrl)); if (response.statusCode < 200 || response.statusCode >= 300) { - return _sortByPrice(tokens); + throw SwapApiException(response.statusCode, response.body); } - final payload = jsonDecode(response.body); - if (payload is! List) return _sortByPrice(tokens); - final rankBySymbol = {}; - final iconBySymbol = {}; + final payload = jsonDecode(response.body) as List; for (var i = 0; i < payload.length; i++) { - final item = payload[i]; - if (item is! Map) continue; - final symbol = (item['symbol'] as String?)?.toUpperCase(); - if (symbol == null || symbol.isEmpty || rankBySymbol.containsKey(symbol)) { - continue; - } + final item = payload[i] as Map; + final symbol = (item['symbol'] as String).toUpperCase(); + if (rankBySymbol.containsKey(symbol)) continue; rankBySymbol[symbol] = i; final icon = item['image'] as String?; if (icon != null && icon.isNotEmpty) iconBySymbol[symbol] = icon; } - final ranked = [ - for (final token in tokens) - token.copyWith(iconUrl: iconBySymbol[token.symbol] ?? token.iconUrl ?? _fallbackTokenIconUrl(token.symbol)), - ]; - ranked.sort((a, b) { - final ar = rankBySymbol[a.symbol] ?? 99999; - final br = rankBySymbol[b.symbol] ?? 99999; - if (ar != br) return ar.compareTo(br); - return b.price.compareTo(a.price); - }); - return ranked; - } catch (_) { - return _sortByPrice(tokens); + } catch (e) { + quantusPrint('CoinGecko ranking failed, sorting swap tokens by price: $e'); } - } - - List<_IntentToken> _sortByPrice(List<_IntentToken> tokens) { - final sorted = [...tokens]; - sorted.sort((a, b) => b.price.compareTo(a.price)); - return sorted; + final ranked = [ + for (final token in tokens) + token.copyWith(iconUrl: iconBySymbol[token.symbol] ?? _fallbackTokenIconUrl(token.symbol)), + ]; + ranked.sort((a, b) { + final ar = rankBySymbol[a.symbol] ?? 99999; + final br = rankBySymbol[b.symbol] ?? 99999; + if (ar != br) return ar.compareTo(br); + return b.usdPrice.compareTo(a.usdPrice); + }); + return ranked; } int _networkPriority(String network) { @@ -334,65 +271,6 @@ class SwapService { } } - Future getQuote({required SwapToken fromToken, required double fromAmount, double slippage = 0.01}) async { - await Future.delayed(const Duration(milliseconds: 500)); - final rate = getRate(fromToken); - final toAmount = fromAmount * rate; - final networkFee = fromAmount * 0.005; - final totalAmount = fromAmount + networkFee; - - return SwapQuote( - quoteId: 'quote_${DateTime.now().millisecondsSinceEpoch}', - fromToken: fromToken, - toToken: _quToken, - fromAmount: fromAmount, - toAmount: toAmount, - rate: rate, - networkFee: networkFee, - totalAmount: totalAmount, - slippageTolerance: slippage, - expiresIn: const Duration(minutes: 5), - ); - } - - Future createSwap(SwapQuote quote) async { - await Future.delayed(const Duration(milliseconds: 300)); - final rng = Random(); - final hex = List.generate(40, (_) => rng.nextInt(16).toRadixString(16)).join(); - final order = SwapOrder( - orderId: 'swap_${DateTime.now().millisecondsSinceEpoch}', - quote: quote, - depositAddress: '0x$hex', - status: SwapStatus.depositing, - createdAt: DateTime.now(), - ); - _orders[order.orderId] = order; - return order; - } - - Future getSwapStatus(String orderId) async { - await Future.delayed(const Duration(milliseconds: 200)); - final order = _orders[orderId]; - if (order == null) throw Exception('Order not found'); - return order; - } - - Future confirmFundsSent(String orderId) async { - await Future.delayed(const Duration(milliseconds: 300)); - final order = _orders[orderId]; - if (order == null) throw Exception('Order not found'); - final updated = order.copyWith(status: SwapStatus.processing); - _orders[orderId] = updated; - - Future.delayed(const Duration(seconds: 5), () { - if (_orders.containsKey(orderId)) { - _orders[orderId] = _orders[orderId]!.copyWith(status: SwapStatus.complete); - } - }); - - return updated; - } - Future addRefundAddress(String network, String address) async { final prefs = await SharedPreferences.getInstance(); final key = '${_refundAddressKey}_${network.toLowerCase()}'; @@ -411,26 +289,3 @@ class SwapService { return prefs.getStringList(key) ?? []; } } - -class _IntentToken extends SwapToken { - final double price; - const _IntentToken({ - required super.symbol, - required super.network, - required super.decimals, - required this.price, - super.iconUrl, - super.networkIconUrl, - }) : super(name: symbol); - - _IntentToken copyWith({String? iconUrl, String? networkIconUrl}) { - return _IntentToken( - symbol: symbol, - network: network, - decimals: decimals, - price: price, - iconUrl: iconUrl ?? this.iconUrl, - networkIconUrl: networkIconUrl ?? this.networkIconUrl, - ); - } -} diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart new file mode 100644 index 000000000..1261e22bc --- /dev/null +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -0,0 +1,334 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +const _usdcEth = SwapToken( + assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near', + symbol: 'USDC', + network: 'ETH', + decimals: 6, + usdPrice: 0.99966, +); +const _wnear = SwapToken(assetId: 'nep141:wrap.near', symbol: 'WNEAR', network: 'NEAR', decimals: 24, usdPrice: 3.47); +const _refund = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; +const _recipient = 'qznQKhufTDfU3szAzfgCny7wMhxUN3qjEqneiRUNgC7MjSDyG'; + +Map _quoteResponse({bool dry = true, String? depositAddress}) => { + 'quote': { + 'depositAddress': ?depositAddress, + 'amountIn': '10000000', + 'amountInFormatted': '10.0', + 'amountInUsd': '9.996620000000', + 'minAmountIn': '10000000', + 'amountOut': '2861051688077884367566500', + 'amountOutFormatted': '2.8610516880778843675665', + 'amountOutUsd': '9.956459874511', + 'minAmountOut': '2832441171197105523890835', + if (!dry) 'deadline': '2026-09-20T06:10:50.000Z', + 'timeEstimate': 45, + 'refundFee': '300000', + 'withdrawFee': '0', + }, + 'quoteRequest': { + 'dry': dry, + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': 100, + 'originAsset': _usdcEth.assetId, + 'destinationAsset': _wnear.assetId, + 'amount': '10000000', + 'refundTo': _refund, + 'recipient': _recipient, + 'deadline': '2026-09-20T06:00:50.000Z', + }, + 'signature': 'ed25519:sig', + 'timestamp': '2026-09-20T05:50:50.975Z', + 'correlationId': 'corr-1', +}; + +Map _statusResponse(String status, {Map? details}) => { + 'correlationId': 'corr-2', + 'status': status, + 'updatedAt': '2026-09-20T05:55:00.000Z', + 'quoteResponse': _quoteResponse(dry: false, depositAddress: '0xdeposit'), + 'swapDetails': ?details, +}; + +SwapService _service(Future Function(http.Request request) handler, {String? apiKey}) => + SwapService(endpoint: 'https://oneclick.test', apiKey: apiKey, client: MockClient(handler)); + +Future _quote(SwapService service, {bool dry = true}) => service.getQuote( + from: _usdcEth, + to: _wnear, + amount: BigInt.from(10000000), + refundAddress: _refund, + recipient: _recipient, + dry: dry, +); + +void main() { + group('getQuote', () { + test('posts the 1Click quote request and parses the quote', () async { + late Map sent; + late http.Request request; + final before = DateTime.now().toUtc(); + final service = _service((r) async { + request = r; + sent = jsonDecode(r.body) as Map; + return http.Response(jsonEncode(_quoteResponse()), 200); + }, apiKey: 'jwt-token'); + + final quote = await _quote(service); + + expect(request.method, 'POST'); + expect(request.url.toString(), 'https://oneclick.test/v0/quote'); + expect(request.headers['X-API-Key'], 'jwt-token'); + expect(sent, { + 'dry': true, + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': 100, + 'originAsset': _usdcEth.assetId, + 'depositType': 'ORIGIN_CHAIN', + 'destinationAsset': _wnear.assetId, + 'amount': '10000000', + 'refundTo': _refund, + 'refundType': 'ORIGIN_CHAIN', + 'recipient': _recipient, + 'recipientType': 'DESTINATION_CHAIN', + 'deadline': isA(), + 'quoteWaitingTimeMs': 3000, + }); + final deadline = DateTime.parse(sent['deadline'] as String); + expect(deadline.difference(before), greaterThanOrEqualTo(SwapService.depositWindow)); + expect(deadline.difference(before), lessThan(SwapService.depositWindow + const Duration(seconds: 10))); + + expect(quote.fromToken, _usdcEth); + expect(quote.toToken, _wnear); + expect(quote.amountIn, BigInt.from(10000000)); + expect(quote.amountOut, BigInt.parse('2861051688077884367566500')); + expect(quote.minAmountOut, BigInt.parse('2832441171197105523890835')); + expect(quote.amountInUsd, closeTo(9.99662, 1e-9)); + expect(quote.amountOutUsd, closeTo(9.956459874511, 1e-9)); + expect(quote.slippageBps, 100); + expect(quote.refundAddress, _refund); + expect(quote.recipient, _recipient); + expect(quote.deadline, DateTime.utc(2026, 9, 20, 6, 0, 50)); + expect(quote.timeEstimate, const Duration(seconds: 45)); + expect(quote.correlationId, 'corr-1'); + expect(quote.depositAddress, isNull); + }); + + test('sends no API key header when none is configured', () async { + late http.Request request; + final service = _service((r) async { + request = r; + return http.Response(jsonEncode(_quoteResponse()), 200); + }); + await _quote(service); + expect(request.headers.containsKey('X-API-Key'), isFalse); + }); + + test('surfaces the server message on a rejected quote', () async { + final service = _service( + (_) async => http.Response('{"message":"refundTo is not valid","correlationId":"c","path":"/v0/quote"}', 400), + ); + await expectLater( + _quote(service), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 400) + .having((e) => e.message, 'message', 'refundTo is not valid'), + ), + ); + }); + + test('joins a list of validation messages', () async { + final service = _service( + (_) async => http.Response('{"message":["amount must be a string","dry required"]}', 400), + ); + await expectLater( + _quote(service), + throwsA(isA().having((e) => e.message, 'message', 'amount must be a string, dry required')), + ); + }); + + test('keeps a non-JSON error body as the message', () async { + final service = _service((_) async => http.Response('502 Bad Gateway', 502)); + await expectLater( + _quote(service), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 502) + .having((e) => e.message, 'message', '502 Bad Gateway'), + ), + ); + }); + }); + + group('createSwap', () { + test('re-quotes live and keeps the deposit address and live deadline', () async { + final sentDry = []; + final service = _service((r) async { + final dry = (jsonDecode(r.body) as Map)['dry'] as bool; + sentDry.add(dry); + return http.Response(jsonEncode(_quoteResponse(dry: dry, depositAddress: dry ? null : '0xdeposit')), 200); + }); + + final order = await service.createSwap(await _quote(service)); + + expect(sentDry, [true, false]); + expect(order.status, SwapStatus.pendingDeposit); + expect(order.depositAddress, '0xdeposit'); + expect(order.quote.deadline, DateTime.utc(2026, 9, 20, 6, 10, 50)); + expect(order.amountOut, isNull); + }); + + test('refuses a live quote without a deposit address', () async { + final service = _service((_) async => http.Response(jsonEncode(_quoteResponse(dry: false)), 200)); + await expectLater(service.createSwap(await _quote(service)), throwsA(isA())); + }); + }); + + group('getSwapStatus', () { + late SwapOrder order; + + setUp(() async { + final service = _service( + (_) async => http.Response(jsonEncode(_quoteResponse(dry: false, depositAddress: '0xdeposit')), 200), + ); + order = await service.createSwap(await _quote(service)); + }); + + test('queries by deposit address and maps every 1Click status', () async { + for (final status in SwapStatus.values) { + late http.Request request; + final service = _service((r) async { + request = r; + return http.Response(jsonEncode(_statusResponse(status.wire)), 200); + }); + final updated = await service.getSwapStatus(order); + expect(request.method, 'GET'); + expect(request.url.toString(), 'https://oneclick.test/v0/status?depositAddress=0xdeposit'); + expect(updated.status, status); + expect(updated.quote, same(order.quote)); + } + }); + + test('reads the settled amounts, refund reason and destination hashes', () async { + final service = _service( + (_) async => http.Response( + jsonEncode( + _statusResponse( + 'SUCCESS', + details: { + 'amountOut': '2850000000000000000000000', + 'refundedAmount': '', + 'destinationChainTxHashes': [ + {'hash': '0xabc', 'explorerUrl': 'https://x/0xabc'}, + ], + }, + ), + ), + 200, + ), + ); + final updated = await service.getSwapStatus(order); + expect(updated.amountOut, BigInt.parse('2850000000000000000000000')); + expect(updated.refundedAmount, isNull); + expect(updated.destinationTxHashes, ['0xabc']); + + final refunded = await _service( + (_) async => http.Response( + jsonEncode( + _statusResponse('REFUNDED', details: {'refundedAmount': '9700000', 'refundReason': 'Quote expired'}), + ), + 200, + ), + ).getSwapStatus(order); + expect(refunded.refundedAmount, BigInt.from(9700000)); + expect(refunded.refundReason, 'Quote expired'); + }); + + test('rejects a status it does not know', () async { + final service = _service((_) async => http.Response(jsonEncode(_statusResponse('SOMETHING_NEW')), 200)); + await expectLater(service.getSwapStatus(order), throwsA(isA())); + }); + + test('throws on an unknown deposit address', () async { + final service = _service( + (_) async => http.Response('{"message":"Deposit address 0xdeposit not found","statusCode":404}', 404), + ); + await expectLater( + service.getSwapStatus(order), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 404)), + ); + }); + }); + + group('SwapStatus', () { + test('only success, refunded and failed are final', () { + expect(SwapStatus.values.where((s) => s.isFinal), [SwapStatus.success, SwapStatus.refunded, SwapStatus.failed]); + }); + }); + + group('getFromTokens', () { + final tokens = [ + {'assetId': 'nep141:wrap.near', 'decimals': 24, 'blockchain': 'near', 'symbol': 'wNEAR', 'price': 3.47}, + {'assetId': 'nep141:base-usdc.omft.near', 'decimals': 6, 'blockchain': 'base', 'symbol': 'USDC', 'price': 1.0}, + {'assetId': _usdcEth.assetId, 'decimals': 6, 'blockchain': 'eth', 'symbol': 'USDC', 'price': 0.99966}, + {'assetId': 'nep141:btc.omft.near', 'decimals': 8, 'blockchain': 'btc', 'symbol': 'BTC', 'price': 80496}, + {'assetId': 'nep141:dead.omft.near', 'decimals': 18, 'blockchain': 'eth', 'symbol': 'DEAD', 'price': 0}, + {'assetId': 'nep141:qtc.omft.near', 'decimals': 12, 'blockchain': 'eth', 'symbol': 'QTC', 'price': 1}, + ]; + + test('keeps one asset per symbol, preferring the main network, ranked by CoinGecko', () async { + final service = _service((r) async { + if (r.url.host == 'api.coingecko.com') { + return http.Response( + jsonEncode([ + {'symbol': 'btc', 'image': 'https://img/btc.png'}, + {'symbol': 'usdc', 'image': 'https://img/usdc.png'}, + ]), + 200, + ); + } + expect(r.url.toString(), 'https://oneclick.test/v0/tokens'); + return http.Response(jsonEncode(tokens), 200); + }); + + final result = await service.getFromTokens(); + + expect(result.map((t) => t.symbol), ['BTC', 'USDC', 'WNEAR']); + final usdc = result[1]; + expect(usdc.assetId, _usdcEth.assetId); + expect(usdc.network, 'ETH'); + expect(usdc.decimals, 6); + expect(usdc.usdPrice, 0.99966); + expect(usdc.iconUrl, 'https://img/usdc.png'); + expect(usdc.networkIconUrl, isNotNull); + expect(result[2].iconUrl, contains('near')); + }); + + test('falls back to price order when CoinGecko is down, and caches', () async { + var tokenCalls = 0; + final service = _service((r) async { + if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); + tokenCalls++; + return http.Response(jsonEncode(tokens), 200); + }); + + expect((await service.getFromTokens()).map((t) => t.symbol), ['BTC', 'WNEAR', 'USDC']); + expect((await service.getFromTokens(limit: 2)).map((t) => t.symbol), ['BTC', 'WNEAR']); + expect(tokenCalls, 1); + await service.getFromTokens(forceRefresh: true); + expect(tokenCalls, 2); + }); + + test('throws when 1Click rejects the token list', () async { + final service = _service((_) async => http.Response('{"message":"nope"}', 500)); + await expectLater(service.getFromTokens(), throwsA(isA())); + }); + }); +} From c273ac46821af6890bb12a760c6cea139e13cfb5 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 21 Sep 2026 00:44:25 +0800 Subject: [PATCH 02/11] Drive the swap screens from live 1Click quotes and status The swap screen loads real tokens, quotes with the active account as recipient, and shows server errors as a toast. The review sheet shows the quoted amount out, minimum received and time estimate, then asks for a live quote on Confirm. The deposit screen shows the real deposit address and deadline, polls status every 5 seconds, and has a view for each status plus an expired one. Demo strings are removed, English and Indonesian updated. The home swap button stays behind showSwapButton. --- mobile-app/lib/l10n/app_en.arb | 134 +++++++-- mobile-app/lib/l10n/app_id.arb | 26 +- mobile-app/lib/l10n/app_localizations.dart | 114 ++++++-- mobile-app/lib/l10n/app_localizations_en.dart | 70 ++++- mobile-app/lib/l10n/app_localizations_id.dart | 70 ++++- mobile-app/lib/v2/components/token_icon.dart | 5 +- .../lib/v2/screens/swap/deposit_screen.dart | 258 +++++++++-------- .../swap/refund_address_picker_sheet.dart | 3 +- .../v2/screens/swap/review_quote_sheet.dart | 115 ++++---- .../lib/v2/screens/swap/swap_providers.dart | 17 ++ .../lib/v2/screens/swap/swap_screen.dart | 259 +++++++++++------- 11 files changed, 725 insertions(+), 346 deletions(-) create mode 100644 mobile-app/lib/v2/screens/swap/swap_providers.dart diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index d1c2cfd9b..30caa6cab 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -2337,26 +2337,50 @@ "@swapTokenPickerLoadError": { "description": "Error when token list fails to load" }, + "swapQuoteError": "Quote failed: {message}", + "@swapQuoteError": { + "description": "Toast when 1Click rejects a quote or a swap cannot be created", + "placeholders": { + "message": { + "type": "String" + } + } + }, "swapReviewTitle": "Review Quote", "@swapReviewTitle": { "description": "Title on review quote sheet" }, - "swapReviewTotalFees": "Total fees", - "@swapReviewTotalFees": { - "description": "Total fees row on review quote sheet" + "swapReviewMinimumReceived": "Minimum received", + "@swapReviewMinimumReceived": { + "description": "Least amount the quote guarantees, on review quote sheet" + }, + "swapReviewEstimatedTime": "Estimated time", + "@swapReviewEstimatedTime": { + "description": "Row label for how long 1Click expects the swap to take" }, - "swapReviewTotalAmount": "Total Amount", - "@swapReviewTotalAmount": { - "description": "Total amount row on review quote sheet" + "swapReviewTimeSeconds": "About {seconds} seconds", + "@swapReviewTimeSeconds": { + "description": "Swap time estimate under a minute", + "placeholders": { + "seconds": { + "type": "int" + } + } }, - "swapReviewSlippageWarning": "You could receive up to ${amount} less based on the {percent}% slippage you set", + "swapReviewTimeMinutes": "About {minutes} minutes", + "@swapReviewTimeMinutes": { + "description": "Swap time estimate of a minute or more", + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "swapReviewSlippageWarning": "The rate locks in when your deposit arrives. With the {percent}% slippage you set, you receive at least the minimum above.", "@swapReviewSlippageWarning": { - "description": "Slippage warning on review quote sheet", + "description": "Slippage note on review quote sheet", "placeholders": { - "amount": { - "type": "String" - }, "percent": { "type": "String" } @@ -2375,9 +2399,22 @@ "@swapDepositAmountCopied": { "description": "Toast when deposit amount is copied" }, - "swapDepositDemoWarning": "For demo purposes only - do not send funds!", - "@swapDepositDemoWarning": { - "description": "Demo warning on deposit screen" + "swapDepositAddressCopied": "Deposit address copied to clipboard", + "@swapDepositAddressCopied": { + "description": "Toast when the deposit address is copied" + }, + "swapDepositWaiting": "Waiting for your deposit…", + "@swapDepositWaiting": { + "description": "Shown while 1Click has not seen a deposit yet" + }, + "swapDepositDeadline": "Send before {time} or the quote expires", + "@swapDepositDeadline": { + "description": "Local time by which the deposit must arrive", + "placeholders": { + "time": { + "type": "String" + } + } }, "swapDepositShareQr": "Share QR", "@swapDepositShareQr": { @@ -2434,17 +2471,68 @@ } } }, - "swapDemoOnly": "Demo Only", - "@swapDemoOnly": { - "description": "Large banner making clear the swap flow is a demo and nothing is swapped" + "swapDepositDetectedBody": "Your deposit was seen on {network}. The swap starts once it is confirmed.", + "@swapDepositDetectedBody": { + "description": "Body while 1Click has seen the deposit transaction but not yet confirmed it", + "placeholders": { + "network": { + "type": "String" + } + } + }, + "swapDepositRefundedTitle": "Swap Refunded", + "@swapDepositRefundedTitle": { + "description": "Title when 1Click refunded the deposit" + }, + "swapDepositRefundedBody": "The swap did not complete. {amount} {symbol} was returned to your refund address.", + "@swapDepositRefundedBody": { + "description": "Body when 1Click refunded the deposit", + "placeholders": { + "amount": { + "type": "String" + }, + "symbol": { + "type": "String" + } + } + }, + "swapDepositRefundReason": "Reason: {reason}", + "@swapDepositRefundReason": { + "description": "Refund reason reported by 1Click", + "placeholders": { + "reason": { + "type": "String" + } + } + }, + "swapDepositFailedTitle": "Swap Failed", + "@swapDepositFailedTitle": { + "description": "Title when 1Click reports the swap failed" + }, + "swapDepositFailedBody": "The swap did not complete. Contact support and quote this deposit address: {address}", + "@swapDepositFailedBody": { + "description": "Body when 1Click reports the swap failed", + "placeholders": { + "address": { + "type": "String" + } + } + }, + "swapDepositIncompleteTitle": "Deposit Too Small", + "@swapDepositIncompleteTitle": { + "description": "Title when less than the quoted amount arrived" + }, + "swapDepositIncompleteBody": "Less than the quoted amount arrived. It will be refunded to your refund address after the deadline.", + "@swapDepositIncompleteBody": { + "description": "Body when less than the quoted amount arrived" }, - "swapDemoOnlyBody": "No real swap was performed.", - "@swapDemoOnlyBody": { - "description": "Subtitle under the Demo Only banner" + "swapDepositExpiredTitle": "Quote Expired", + "@swapDepositExpiredTitle": { + "description": "Title when the deadline passed with no deposit" }, - "swapDepositSentFunds": "I've sent the funds", - "@swapDepositSentFunds": { - "description": "Button to confirm funds sent" + "swapDepositExpiredBody": "No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.", + "@swapDepositExpiredBody": { + "description": "Body when the deadline passed with no deposit" }, "swapDepositDone": "Done", "@swapDepositDone": { diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 2838cac47..c3482e383 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -451,14 +451,19 @@ "swapRateZero": "1 {tokenSymbol} = 0 {symbol}", "swapTokenPickerTitle": "Pilih Token", "swapTokenPickerLoadError": "Gagal memuat token", + "swapQuoteError": "Penawaran gagal: {message}", "swapReviewTitle": "Tinjau Penawaran", - "swapReviewTotalFees": "Total biaya", - "swapReviewTotalAmount": "Jumlah Total", - "swapReviewSlippageWarning": "Anda bisa menerima hingga ${amount} lebih sedikit berdasarkan slippage {percent}% yang Anda atur", + "swapReviewMinimumReceived": "Minimum diterima", + "swapReviewEstimatedTime": "Perkiraan waktu", + "swapReviewTimeSeconds": "Sekitar {seconds} detik", + "swapReviewTimeMinutes": "Sekitar {minutes} menit", + "swapReviewSlippageWarning": "Kurs dikunci saat deposit Anda tiba. Dengan slippage {percent}% yang Anda atur, Anda menerima setidaknya minimum di atas.", "swapReviewConfirm": "Konfirmasi", "swapDepositAmount": "Jumlah Deposit", "swapDepositAmountCopied": "Jumlah deposit disalin ke clipboard", - "swapDepositDemoWarning": "Hanya untuk demo - jangan kirim dana!", + "swapDepositAddressCopied": "Alamat deposit disalin ke clipboard", + "swapDepositWaiting": "Menunggu deposit Anda…", + "swapDepositDeadline": "Kirim sebelum {time} atau penawaran kedaluwarsa", "swapDepositShareQr": "Bagikan QR", "swapDepositShareContent": "Jaringan: {network}\nToken: {token}\nAlamat: {address}", "swapDepositNotice": "Gunakan dompet {symbol} atau {network} Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.", @@ -466,9 +471,16 @@ "swapDepositProcessingBody": "Ini mungkin memakan waktu beberapa menit...", "swapDepositCompleteTitle": "Swap Selesai", "swapDepositCompleteBody": "Swap Anda untuk {amount} {tokenSymbol} telah selesai.", - "swapDemoOnly": "Hanya Demo", - "swapDemoOnlyBody": "Tidak ada swap sungguhan yang dilakukan.", - "swapDepositSentFunds": "Saya sudah mengirim dana", + "swapDepositDetectedBody": "Deposit Anda terlihat di {network}. Swap dimulai setelah dikonfirmasi.", + "swapDepositRefundedTitle": "Swap Dikembalikan", + "swapDepositRefundedBody": "Swap tidak selesai. {amount} {symbol} dikembalikan ke alamat refund Anda.", + "swapDepositRefundReason": "Alasan: {reason}", + "swapDepositFailedTitle": "Swap Gagal", + "swapDepositFailedBody": "Swap tidak selesai. Hubungi dukungan dan sebutkan alamat deposit ini: {address}", + "swapDepositIncompleteTitle": "Deposit Terlalu Kecil", + "swapDepositIncompleteBody": "Jumlah yang tiba kurang dari penawaran. Dana akan dikembalikan ke alamat refund Anda setelah tenggat waktu.", + "swapDepositExpiredTitle": "Penawaran Kedaluwarsa", + "swapDepositExpiredBody": "Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.", "swapDepositDone": "Selesai", "swapRefundPickerTitle": "Alamat Refund", "swapRefundPickerEmpty": "Tidak ada alamat refund terbaru", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 1b43f88de..723513ac9 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -3032,29 +3032,47 @@ abstract class AppLocalizations { /// **'Failed to load tokens'** String get swapTokenPickerLoadError; + /// Toast when 1Click rejects a quote or a swap cannot be created + /// + /// In en, this message translates to: + /// **'Quote failed: {message}'** + String swapQuoteError(String message); + /// Title on review quote sheet /// /// In en, this message translates to: /// **'Review Quote'** String get swapReviewTitle; - /// Total fees row on review quote sheet + /// Least amount the quote guarantees, on review quote sheet + /// + /// In en, this message translates to: + /// **'Minimum received'** + String get swapReviewMinimumReceived; + + /// Row label for how long 1Click expects the swap to take + /// + /// In en, this message translates to: + /// **'Estimated time'** + String get swapReviewEstimatedTime; + + /// Swap time estimate under a minute /// /// In en, this message translates to: - /// **'Total fees'** - String get swapReviewTotalFees; + /// **'About {seconds} seconds'** + String swapReviewTimeSeconds(int seconds); - /// Total amount row on review quote sheet + /// Swap time estimate of a minute or more /// /// In en, this message translates to: - /// **'Total Amount'** - String get swapReviewTotalAmount; + /// **'About {minutes} minutes'** + String swapReviewTimeMinutes(int minutes); - /// Slippage warning on review quote sheet + /// Slippage note on review quote sheet /// /// In en, this message translates to: - /// **'You could receive up to \${amount} less based on the {percent}% slippage you set'** - String swapReviewSlippageWarning(String amount, String percent); + /// **'The rate locks in when your deposit arrives. With the {percent}% slippage you set, you receive at least the minimum above.'** + String swapReviewSlippageWarning(String percent); /// Confirm button on review quote sheet /// @@ -3074,11 +3092,23 @@ abstract class AppLocalizations { /// **'Deposit amount copied to clipboard'** String get swapDepositAmountCopied; - /// Demo warning on deposit screen + /// Toast when the deposit address is copied /// /// In en, this message translates to: - /// **'For demo purposes only - do not send funds!'** - String get swapDepositDemoWarning; + /// **'Deposit address copied to clipboard'** + String get swapDepositAddressCopied; + + /// Shown while 1Click has not seen a deposit yet + /// + /// In en, this message translates to: + /// **'Waiting for your deposit…'** + String get swapDepositWaiting; + + /// Local time by which the deposit must arrive + /// + /// In en, this message translates to: + /// **'Send before {time} or the quote expires'** + String swapDepositDeadline(String time); /// Share QR button on deposit screen /// @@ -3122,23 +3152,65 @@ abstract class AppLocalizations { /// **'Your swap for {amount} {tokenSymbol} is complete.'** String swapDepositCompleteBody(String amount, String tokenSymbol); - /// Large banner making clear the swap flow is a demo and nothing is swapped + /// Body while 1Click has seen the deposit transaction but not yet confirmed it + /// + /// In en, this message translates to: + /// **'Your deposit was seen on {network}. The swap starts once it is confirmed.'** + String swapDepositDetectedBody(String network); + + /// Title when 1Click refunded the deposit + /// + /// In en, this message translates to: + /// **'Swap Refunded'** + String get swapDepositRefundedTitle; + + /// Body when 1Click refunded the deposit + /// + /// In en, this message translates to: + /// **'The swap did not complete. {amount} {symbol} was returned to your refund address.'** + String swapDepositRefundedBody(String amount, String symbol); + + /// Refund reason reported by 1Click + /// + /// In en, this message translates to: + /// **'Reason: {reason}'** + String swapDepositRefundReason(String reason); + + /// Title when 1Click reports the swap failed + /// + /// In en, this message translates to: + /// **'Swap Failed'** + String get swapDepositFailedTitle; + + /// Body when 1Click reports the swap failed + /// + /// In en, this message translates to: + /// **'The swap did not complete. Contact support and quote this deposit address: {address}'** + String swapDepositFailedBody(String address); + + /// Title when less than the quoted amount arrived + /// + /// In en, this message translates to: + /// **'Deposit Too Small'** + String get swapDepositIncompleteTitle; + + /// Body when less than the quoted amount arrived /// /// In en, this message translates to: - /// **'Demo Only'** - String get swapDemoOnly; + /// **'Less than the quoted amount arrived. It will be refunded to your refund address after the deadline.'** + String get swapDepositIncompleteBody; - /// Subtitle under the Demo Only banner + /// Title when the deadline passed with no deposit /// /// In en, this message translates to: - /// **'No real swap was performed.'** - String get swapDemoOnlyBody; + /// **'Quote Expired'** + String get swapDepositExpiredTitle; - /// Button to confirm funds sent + /// Body when the deadline passed with no deposit /// /// In en, this message translates to: - /// **'I\'ve sent the funds'** - String get swapDepositSentFunds; + /// **'No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.'** + String get swapDepositExpiredBody; /// Done button after swap completes /// diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index dc49cdc40..9b1d1b882 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1610,18 +1610,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get swapTokenPickerLoadError => 'Failed to load tokens'; + @override + String swapQuoteError(String message) { + return 'Quote failed: $message'; + } + @override String get swapReviewTitle => 'Review Quote'; @override - String get swapReviewTotalFees => 'Total fees'; + String get swapReviewMinimumReceived => 'Minimum received'; + + @override + String get swapReviewEstimatedTime => 'Estimated time'; + + @override + String swapReviewTimeSeconds(int seconds) { + return 'About $seconds seconds'; + } @override - String get swapReviewTotalAmount => 'Total Amount'; + String swapReviewTimeMinutes(int minutes) { + return 'About $minutes minutes'; + } @override - String swapReviewSlippageWarning(String amount, String percent) { - return 'You could receive up to \$$amount less based on the $percent% slippage you set'; + String swapReviewSlippageWarning(String percent) { + return 'The rate locks in when your deposit arrives. With the $percent% slippage you set, you receive at least the minimum above.'; } @override @@ -1634,7 +1649,15 @@ class AppLocalizationsEn extends AppLocalizations { String get swapDepositAmountCopied => 'Deposit amount copied to clipboard'; @override - String get swapDepositDemoWarning => 'For demo purposes only - do not send funds!'; + String get swapDepositAddressCopied => 'Deposit address copied to clipboard'; + + @override + String get swapDepositWaiting => 'Waiting for your deposit…'; + + @override + String swapDepositDeadline(String time) { + return 'Send before $time or the quote expires'; + } @override String get swapDepositShareQr => 'Share QR'; @@ -1664,13 +1687,44 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get swapDemoOnly => 'Demo Only'; + String swapDepositDetectedBody(String network) { + return 'Your deposit was seen on $network. The swap starts once it is confirmed.'; + } + + @override + String get swapDepositRefundedTitle => 'Swap Refunded'; + + @override + String swapDepositRefundedBody(String amount, String symbol) { + return 'The swap did not complete. $amount $symbol was returned to your refund address.'; + } + + @override + String swapDepositRefundReason(String reason) { + return 'Reason: $reason'; + } + + @override + String get swapDepositFailedTitle => 'Swap Failed'; + + @override + String swapDepositFailedBody(String address) { + return 'The swap did not complete. Contact support and quote this deposit address: $address'; + } + + @override + String get swapDepositIncompleteTitle => 'Deposit Too Small'; + + @override + String get swapDepositIncompleteBody => + 'Less than the quoted amount arrived. It will be refunded to your refund address after the deadline.'; @override - String get swapDemoOnlyBody => 'No real swap was performed.'; + String get swapDepositExpiredTitle => 'Quote Expired'; @override - String get swapDepositSentFunds => 'I\'ve sent the funds'; + String get swapDepositExpiredBody => + 'No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.'; @override String get swapDepositDone => 'Done'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index d3da59cfc..0691fa3e0 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -1613,18 +1613,33 @@ class AppLocalizationsId extends AppLocalizations { @override String get swapTokenPickerLoadError => 'Gagal memuat token'; + @override + String swapQuoteError(String message) { + return 'Penawaran gagal: $message'; + } + @override String get swapReviewTitle => 'Tinjau Penawaran'; @override - String get swapReviewTotalFees => 'Total biaya'; + String get swapReviewMinimumReceived => 'Minimum diterima'; + + @override + String get swapReviewEstimatedTime => 'Perkiraan waktu'; + + @override + String swapReviewTimeSeconds(int seconds) { + return 'Sekitar $seconds detik'; + } @override - String get swapReviewTotalAmount => 'Jumlah Total'; + String swapReviewTimeMinutes(int minutes) { + return 'Sekitar $minutes menit'; + } @override - String swapReviewSlippageWarning(String amount, String percent) { - return 'Anda bisa menerima hingga \$$amount lebih sedikit berdasarkan slippage $percent% yang Anda atur'; + String swapReviewSlippageWarning(String percent) { + return 'Kurs dikunci saat deposit Anda tiba. Dengan slippage $percent% yang Anda atur, Anda menerima setidaknya minimum di atas.'; } @override @@ -1637,7 +1652,15 @@ class AppLocalizationsId extends AppLocalizations { String get swapDepositAmountCopied => 'Jumlah deposit disalin ke clipboard'; @override - String get swapDepositDemoWarning => 'Hanya untuk demo - jangan kirim dana!'; + String get swapDepositAddressCopied => 'Alamat deposit disalin ke clipboard'; + + @override + String get swapDepositWaiting => 'Menunggu deposit Anda…'; + + @override + String swapDepositDeadline(String time) { + return 'Kirim sebelum $time atau penawaran kedaluwarsa'; + } @override String get swapDepositShareQr => 'Bagikan QR'; @@ -1667,13 +1690,44 @@ class AppLocalizationsId extends AppLocalizations { } @override - String get swapDemoOnly => 'Hanya Demo'; + String swapDepositDetectedBody(String network) { + return 'Deposit Anda terlihat di $network. Swap dimulai setelah dikonfirmasi.'; + } + + @override + String get swapDepositRefundedTitle => 'Swap Dikembalikan'; + + @override + String swapDepositRefundedBody(String amount, String symbol) { + return 'Swap tidak selesai. $amount $symbol dikembalikan ke alamat refund Anda.'; + } + + @override + String swapDepositRefundReason(String reason) { + return 'Alasan: $reason'; + } + + @override + String get swapDepositFailedTitle => 'Swap Gagal'; + + @override + String swapDepositFailedBody(String address) { + return 'Swap tidak selesai. Hubungi dukungan dan sebutkan alamat deposit ini: $address'; + } + + @override + String get swapDepositIncompleteTitle => 'Deposit Terlalu Kecil'; + + @override + String get swapDepositIncompleteBody => + 'Jumlah yang tiba kurang dari penawaran. Dana akan dikembalikan ke alamat refund Anda setelah tenggat waktu.'; @override - String get swapDemoOnlyBody => 'Tidak ada swap sungguhan yang dilakukan.'; + String get swapDepositExpiredTitle => 'Penawaran Kedaluwarsa'; @override - String get swapDepositSentFunds => 'Saya sudah mengirim dana'; + String get swapDepositExpiredBody => + 'Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.'; @override String get swapDepositDone => 'Selesai'; diff --git a/mobile-app/lib/v2/components/token_icon.dart b/mobile-app/lib/v2/components/token_icon.dart index 07a2f9064..a93d99d68 100644 --- a/mobile-app/lib/v2/components/token_icon.dart +++ b/mobile-app/lib/v2/components/token_icon.dart @@ -12,9 +12,8 @@ class TokenIcon extends StatelessWidget { Widget build(BuildContext context) { final colors = context.colorsV3; final text = context.themeTextV3; - final swapService = SwapService(); - final iconUrl = token.iconUrl ?? swapService.getTokenIconUrl(token); - final networkIconUrl = token.networkIconUrl ?? swapService.getNetworkIconUrl(token); + final iconUrl = token.iconUrl; + final networkIconUrl = token.networkIconUrl; return SizedBox( width: size, diff --git a/mobile-app/lib/v2/screens/swap/deposit_screen.dart b/mobile-app/lib/v2/screens/swap/deposit_screen.dart index e6b1221d6..3b294621e 100644 --- a/mobile-app/lib/v2/screens/swap/deposit_screen.dart +++ b/mobile-app/lib/v2/screens/swap/deposit_screen.dart @@ -1,16 +1,22 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; -import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/shared/utils/share_utils.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/success_check.dart'; import 'package:resonance_network_wallet/v2/components/token_icon.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; +/// Shows where to send the origin-chain funds and follows the swap from +/// 1Click's status endpoint until it settles, refunds, or fails. class DepositScreen extends ConsumerStatefulWidget { final SwapOrder order; const DepositScreen({super.key, required this.order}); @@ -20,65 +26,83 @@ class DepositScreen extends ConsumerStatefulWidget { } class _DepositScreenState extends ConsumerState { - final _swapService = SwapService(); late SwapOrder _order; - bool _confirming = false; - - /// This swap flow is demo-only — no real deposit is ever expected. To make it - /// impossible to accidentally send funds, the QR code, the on-screen address, - /// and the copy/share actions all surface this warning instead of the real - /// deposit address. Scanning the QR decodes to this plain text, not an - /// address, so no wallet can act on it. - static const String _demoWarningPayload = 'demo only - do not send funds'; + Timer? _timer; + bool _polling = false; @override void initState() { super.initState(); _order = widget.order; + _timer = Timer.periodic(SwapService.statusPollInterval, (_) => _poll()); } - Future _confirmSent() async { - setState(() => _confirming = true); - try { - final updated = await _swapService.confirmFundsSent(_order.orderId); - if (!mounted) return; - setState(() { - _order = updated; - _confirming = false; - }); - _pollStatus(); - } catch (e) { - quantusPrint('Confirm funds sent failed: $e'); - setState(() => _confirming = false); - } + @override + void dispose() { + _timer?.cancel(); + super.dispose(); } - Future _pollStatus() async { - while (mounted && _order.status == SwapStatus.processing) { - await Future.delayed(const Duration(seconds: 2)); + Future _poll() async { + if (_polling) return; + _polling = true; + try { + final updated = await ref.read(swapServiceProvider).getSwapStatus(_order); if (!mounted) return; - try { - final updated = await _swapService.getSwapStatus(_order.orderId); - if (!mounted) return; - setState(() => _order = updated); - } catch (e) { - quantusPrint('Swap status poll failed: $e'); - } + setState(() => _order = updated); + if (updated.status.isFinal) _timer?.cancel(); + } catch (e) { + quantusPrint('Swap status poll failed: $e'); + } finally { + _polling = false; } } - void _copyAddress(AppLocalizations l10n) { - // Demo-only: never expose the real deposit address (see _demoWarningPayload). - context.copyTextWithToaster(_demoWarningPayload); - } + bool get _expired => _order.status == SwapStatus.pendingDeposit && DateTime.now().isAfter(_order.quote.deadline); @override Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); final colors = context.colorsV3; final text = context.themeTextV3; + final fmt = ref.watch(numberFormattingServiceProvider); final quote = _order.quote; - final usd = quote.fromAmount * _swapService.getUsdPrice(quote.fromToken); + final from = quote.fromToken; + + final body = switch (_order.status) { + SwapStatus.pendingDeposit when _expired => _outcome( + colors, + text, + icon: Icon(Icons.timer_off_outlined, color: colors.textMuted, size: 64), + title: l10n.swapDepositExpiredTitle, + body: l10n.swapDepositExpiredBody, + ), + SwapStatus.pendingDeposit => _depositBody(l10n, colors, text, fmt), + SwapStatus.knownDepositTx => _processingBody(l10n, colors, text, l10n.swapDepositDetectedBody(from.network)), + SwapStatus.processing => _processingBody(l10n, colors, text, l10n.swapDepositProcessingBody), + SwapStatus.incompleteDeposit => _outcome( + colors, + text, + icon: Icon(Icons.warning_amber_rounded, color: colors.semanticSand, size: 64), + title: l10n.swapDepositIncompleteTitle, + body: l10n.swapDepositIncompleteBody, + ), + SwapStatus.success => _completedBody(l10n, colors, text, fmt), + SwapStatus.refunded => _outcome( + colors, + text, + icon: Icon(Icons.undo, color: colors.semanticSand, size: 64), + title: l10n.swapDepositRefundedTitle, + body: _refundedText(l10n, fmt), + ), + SwapStatus.failed => _outcome( + colors, + text, + icon: Icon(Icons.error_outline, color: colors.semanticEmber, size: 64), + title: l10n.swapDepositFailedTitle, + body: l10n.swapDepositFailedBody(_order.depositAddress), + ), + }; return ScaffoldBase( appBar: V2AppBar( @@ -87,22 +111,27 @@ class _DepositScreenState extends ConsumerState { ), mainContent: Column( children: [ - if (_order.status == SwapStatus.complete) - _completedBody(l10n, colors, text) - else if (_order.status == SwapStatus.processing) - _processingBody(l10n, colors, text) - else - _depositBody(l10n, colors, text, quote, usd), - const Spacer(), - if (_order.status == SwapStatus.depositing) _sentButton(l10n), - if (_order.status == SwapStatus.complete) _doneButton(l10n), + Expanded(child: SingleChildScrollView(child: body)), + if (_order.status.isFinal || _expired) ...[const SizedBox(height: 16), _doneButton(l10n)], const SizedBox(height: 24), ], ), ); } - Widget _depositBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapQuote quote, double usd) { + String _refundedText(AppLocalizations l10n, NumberFormattingService fmt) { + final from = _order.quote.fromToken; + final amount = fmt.formatAmount(_order.refundedAmount ?? _order.quote.amountIn, decimals: from.decimals); + final reason = _order.refundReason; + final body = l10n.swapDepositRefundedBody(amount, from.symbol); + return reason == null ? body : '$body\n\n${l10n.swapDepositRefundReason(reason)}'; + } + + Widget _depositBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, NumberFormattingService fmt) { + final quote = _order.quote; + final from = quote.fromToken; + final address = _order.depositAddress; + final deadline = TimeOfDay.fromDateTime(quote.deadline.toLocal()).format(context); return Column( children: [ Row( @@ -112,7 +141,7 @@ class _DepositScreenState extends ConsumerState { const SizedBox(width: 6), GestureDetector( onTap: () => context.copyTextWithToaster( - SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken), + fmt.formatWireAmount(quote.amountIn, decimals: from.decimals), message: l10n.swapDepositAmountCopied, ), child: Container( @@ -128,58 +157,46 @@ class _DepositScreenState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - TokenIcon(token: quote.fromToken, size: 28, networkBadgeSize: 11), + TokenIcon(token: from, size: 28, networkBadgeSize: 11), const SizedBox(width: 8), Text( - SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken), + fmt.formatAmount(quote.amountIn, decimals: from.decimals), style: text.amountHero.copyWith(color: colors.textContent), ), ], ), const SizedBox(height: 8), - Text('\$${usd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)), - const SizedBox(height: 40), + Text('\$${quote.amountInUsd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)), + const SizedBox(height: 24), ClipRRect( borderRadius: context.radiusV3.smBorder, child: Container( color: colors.textWhite, padding: const EdgeInsets.all(8), - child: QrImageView(data: _demoWarningPayload, version: QrVersions.auto, size: 184), + child: QrImageView(data: address, version: QrVersions.auto, size: 184), ), ), const SizedBox(height: 16), - // SizedBox( - // width: 264, - // child: Stack( - // children: [ - // Text( - // _demoWarningPayload, - // style: text.smallParagraph?.copyWith( - // color: colors.textPrimary, - // fontWeight: FontWeight.w500, - // height: 1.35, - // ), - // textAlign: TextAlign.center, - // ), - // Positioned( - // right: 0, - // top: 19, - // child: GestureDetector( - // onTap: () => _copyAddress(l10n), - // child: Container( - // width: 20, - // height: 20, - // decoration: BoxDecoration(color: colors.surfaceGlass, borderRadius: BorderRadius.circular(4)), - // child: Center(child: Icon(Icons.copy, color: colors.textPrimary, size: 12)), - // ), - // ), - // ), - // ], - // ), - // ), + GestureDetector( + onTap: () => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied), + child: Text( + address, + style: text.dataAddress.copyWith(color: colors.textContent), + textAlign: TextAlign.center, + ), + ), const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Loader(color: colors.semanticSage, size: 14), + const SizedBox(width: 8), + Text(l10n.swapDepositWaiting, style: text.caption.copyWith(color: colors.textMuted)), + ], + ), + const SizedBox(height: 8), Text( - l10n.swapDepositDemoWarning, + l10n.swapDepositDeadline(deadline), style: text.bodyEmphasis.copyWith(color: colors.accentFlare), textAlign: TextAlign.center, ), @@ -190,7 +207,7 @@ class _DepositScreenState extends ConsumerState { child: QuantusButton.simple( label: l10n.receiveCopy, variant: ButtonVariant.staged, - onTap: () => _copyAddress(l10n), + onTap: () => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied), icon: Icon(Icons.copy, color: colors.textContent, size: 20), iconPlacement: IconPlacement.leading, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), @@ -204,23 +221,14 @@ class _DepositScreenState extends ConsumerState { iconPlacement: IconPlacement.leading, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), variant: ButtonVariant.staged, - onTap: () { - shareText( - context, - l10n.swapDepositShareContent( - _order.quote.fromToken.network, - _order.quote.fromToken.symbol, - _demoWarningPayload, - ), - ); - }, + onTap: () => shareText(context, l10n.swapDepositShareContent(from.network, from.symbol, address)), ), ), ], ), - const SizedBox(height: 40), + const SizedBox(height: 24), Text( - l10n.swapDepositNotice(quote.fromToken.symbol, quote.fromToken.network), + l10n.swapDepositNotice(from.symbol, from.network), style: text.caption.copyWith(color: colors.textMuted), textAlign: TextAlign.center, ), @@ -228,7 +236,7 @@ class _DepositScreenState extends ConsumerState { ); } - Widget _processingBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _processingBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, String body) { return Column( children: [ const SizedBox(height: 80), @@ -236,14 +244,18 @@ class _DepositScreenState extends ConsumerState { const SizedBox(height: 32), Text(l10n.swapDepositProcessingTitle, style: text.titleScreen.copyWith(color: colors.textContent)), const SizedBox(height: 12), - Text(l10n.swapDepositProcessingBody, style: text.bodyLarge.copyWith(color: colors.textMuted)), + Text( + body, + style: text.bodyLarge.copyWith(color: colors.textMuted), + textAlign: TextAlign.center, + ), ], ); } - Widget _completedBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { - final amount = SwapService.formatTokenAmount(_order.quote.toAmount, _order.quote.toToken); - + Widget _completedBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, NumberFormattingService fmt) { + final to = _order.quote.toToken; + final amount = fmt.formatAmount(_order.amountOut ?? _order.quote.amountOut, decimals: to.decimals); return Column( children: [ const SizedBox(height: 80), @@ -252,19 +264,34 @@ class _DepositScreenState extends ConsumerState { Text(l10n.swapDepositCompleteTitle, style: text.titleSuccess.copyWith(color: colors.textContent)), const SizedBox(height: 12), Text( - l10n.swapDepositCompleteBody(amount, AppConstants.tokenSymbol), + l10n.swapDepositCompleteBody(amount, to.symbol), style: text.bodyLarge.copyWith(color: colors.textMuted), textAlign: TextAlign.center, ), - const SizedBox(height: 40), + ], + ); + } + + Widget _outcome( + AppColorsV3 colors, + AppTextThemeV3 text, { + required Widget icon, + required String title, + required String body, + }) { + return Column( + children: [ + const SizedBox(height: 80), + icon, + const SizedBox(height: 32), Text( - l10n.swapDemoOnly, - style: text.titleHero.copyWith(color: colors.accentFlare), + title, + style: text.titleScreen.copyWith(color: colors.textContent), textAlign: TextAlign.center, ), - const SizedBox(height: 8), + const SizedBox(height: 12), Text( - l10n.swapDemoOnlyBody, + body, style: text.bodyLarge.copyWith(color: colors.textMuted), textAlign: TextAlign.center, ), @@ -272,15 +299,6 @@ class _DepositScreenState extends ConsumerState { ); } - Widget _sentButton(AppLocalizations l10n) { - return QuantusButton.simple( - label: l10n.swapDepositSentFunds, - onTap: _confirmSent, - variant: ButtonVariant.staged, - isLoading: _confirming, - ); - } - Widget _doneButton(AppLocalizations l10n) { return QuantusButton.simple( label: l10n.swapDepositDone, diff --git a/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart index 0d6be26ae..b1119da16 100644 --- a/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart +++ b/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; Future showRefundAddressPickerSheet(BuildContext context, String network) { return BottomSheetContainer.show(context, builder: (_) => _RefundAddressPickerContent(network: network)); @@ -25,7 +26,7 @@ class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPicke } Future _load() async { - final addresses = await SwapService().getRefundAddresses(widget.network); + final addresses = await ref.read(swapServiceProvider).getRefundAddresses(widget.network); if (mounted) setState(() => _addresses = addresses); } diff --git a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart index cb3f181f5..910f9307e 100644 --- a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart +++ b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart @@ -1,31 +1,60 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/token_icon.dart'; import 'package:resonance_network_wallet/v2/screens/swap/deposit_screen.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; -void showReviewQuoteSheet(BuildContext context, SwapQuote quote, String refundAddress) { - BottomSheetContainer.show( - context, - builder: (_) => _ReviewQuoteContent(quote: quote, refundAddress: refundAddress), - ); +void showReviewQuoteSheet(BuildContext context, SwapQuote quote) { + BottomSheetContainer.show(context, builder: (_) => _ReviewQuoteContent(quote: quote)); } -class _ReviewQuoteContent extends ConsumerWidget { +class _ReviewQuoteContent extends ConsumerStatefulWidget { final SwapQuote quote; - final String refundAddress; - const _ReviewQuoteContent({required this.quote, required this.refundAddress}); + const _ReviewQuoteContent({required this.quote}); + + @override + ConsumerState<_ReviewQuoteContent> createState() => _ReviewQuoteContentState(); +} + +class _ReviewQuoteContentState extends ConsumerState<_ReviewQuoteContent> { + bool _confirming = false; + + SwapQuote get quote => widget.quote; + + Future _confirm() async { + final navigator = Navigator.of(context); + setState(() => _confirming = true); + try { + final order = await ref.read(swapServiceProvider).createSwap(quote); + if (!mounted) return; + navigator.pop(); + navigator.push(MaterialPageRoute(builder: (_) => DepositScreen(order: order))); + } catch (e) { + quantusPrint('Swap create failed: $e'); + if (!mounted) return; + context.showErrorToaster(message: ref.read(l10nProvider).swapQuoteError(describeSwapError(e))); + } finally { + if (mounted) setState(() => _confirming = false); + } + } + + String _timeLabel(AppLocalizations l10n) { + final seconds = quote.timeEstimate.inSeconds; + return seconds < 60 ? l10n.swapReviewTimeSeconds(seconds) : l10n.swapReviewTimeMinutes((seconds / 60).ceil()); + } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final l10n = ref.watch(l10nProvider); final colors = context.colorsV3; final text = context.themeTextV3; - final swapService = SwapService(); - final fromUsd = quote.fromAmount * swapService.getUsdPrice(quote.fromToken); - final toUsd = quote.toAmount * swapService.getUsdPrice(quote.toToken); + final fmt = ref.watch(numberFormattingServiceProvider); + final to = quote.toToken; return BottomSheetContainer( title: l10n.swapReviewTitle, @@ -33,45 +62,42 @@ class _ReviewQuoteContent extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _swapVisual(context, colors, text, fromUsd, toUsd), + _swapVisual(context, fmt, colors, text), const SizedBox(height: 48), - _feeRow( - l10n.swapReviewTotalFees, - '${SwapService.formatTokenAmount(quote.networkFee, quote.fromToken)} ${quote.fromToken.symbol}', - colors, - text, - ), - const Padding(padding: EdgeInsets.symmetric(vertical: 16), child: MenuDivider()), - _feeRow( - l10n.swapReviewTotalAmount, - '${SwapService.formatTokenAmount(quote.totalAmount, quote.fromToken)} ${quote.fromToken.symbol}', + _row( + l10n.swapReviewMinimumReceived, + '${fmt.formatAmount(quote.minAmountOut, decimals: to.decimals)} ${to.symbol}', colors, text, highlight: true, ), + const Padding(padding: EdgeInsets.symmetric(vertical: 16), child: MenuDivider()), + _row(l10n.swapReviewEstimatedTime, _timeLabel(l10n), colors, text), const SizedBox(height: 24), Text( - l10n.swapReviewSlippageWarning( - (quote.fromAmount * quote.slippageTolerance).toStringAsFixed(2), - (quote.slippageTolerance * 100).toStringAsFixed(0), - ), + l10n.swapReviewSlippageWarning(slippagePercentLabel(quote.slippageBps)), style: text.caption.copyWith(color: colors.textMuted), ), const SizedBox(height: 24), - _confirmButton(context, l10n), + QuantusButton.simple( + label: l10n.swapReviewConfirm, + variant: ButtonVariant.staged, + onTap: _confirm, + isLoading: _confirming, + ), ], ), ); } - Widget _swapVisual(BuildContext context, AppColorsV3 colors, AppTextThemeV3 text, double fromUsd, double toUsd) { + Widget _swapVisual(BuildContext context, NumberFormattingService fmt, AppColorsV3 colors, AppTextThemeV3 text) { final cardWidth = MediaQuery.of(context).size.width / 3; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _tokenCard(context, quote.fromToken, quote.fromAmount, fromUsd, cardWidth, colors, text), + _tokenCard(context, quote.fromToken, quote.amountIn, quote.amountInUsd, cardWidth, fmt, colors, text), Icon(Icons.arrow_forward, color: colors.textMuted, size: 20), - _tokenCard(context, quote.toToken, quote.toAmount, toUsd, cardWidth, colors, text), + _tokenCard(context, quote.toToken, quote.amountOut, quote.amountOutUsd, cardWidth, fmt, colors, text), ], ); } @@ -79,9 +105,10 @@ class _ReviewQuoteContent extends ConsumerWidget { Widget _tokenCard( BuildContext context, SwapToken token, - double amount, + BigInt amount, double usd, double width, + NumberFormattingService fmt, AppColorsV3 colors, AppTextThemeV3 text, ) { @@ -108,14 +135,18 @@ class _ReviewQuoteContent extends ConsumerWidget { ], ), const SizedBox(height: 6), - Text(SwapService.formatTokenAmount(amount, token), style: text.amountRow.copyWith(color: colors.textContent)), + Text( + fmt.formatAmount(amount, decimals: token.decimals), + style: text.amountRow.copyWith(color: colors.textContent), + overflow: TextOverflow.ellipsis, + ), Text('\$${usd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), ], ), ); } - Widget _feeRow(String label, String value, AppColorsV3 colors, AppTextThemeV3 text, {bool highlight = false}) { + Widget _row(String label, String value, AppColorsV3 colors, AppTextThemeV3 text, {bool highlight = false}) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -124,18 +155,4 @@ class _ReviewQuoteContent extends ConsumerWidget { ], ); } - - Widget _confirmButton(BuildContext context, AppLocalizations l10n) { - return QuantusButton.simple( - label: l10n.swapReviewConfirm, - variant: ButtonVariant.staged, - onTap: () async { - final swapService = SwapService(); - final order = await swapService.createSwap(quote); - if (!context.mounted) return; - Navigator.pop(context); - Navigator.push(context, MaterialPageRoute(builder: (_) => DepositScreen(order: order))); - }, - ); - } } diff --git a/mobile-app/lib/v2/screens/swap/swap_providers.dart b/mobile-app/lib/v2/screens/swap/swap_providers.dart new file mode 100644 index 000000000..f2ae6a368 --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/swap_providers.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; + +final swapServiceProvider = Provider((_) => SwapService()); + +/// QTC as a swap destination, priced with the same rate the rest of the app uses. +final quantusSwapTokenProvider = Provider( + (ref) => SwapService.quantusToken(usdPrice: ref.watch(exchangeRateServiceProvider).tokenToUsdRate.toDouble()), +); + +String describeSwapError(Object error) => error is SwapApiException ? error.message : error.toString(); + +String slippagePercentLabel(int bps) { + final percent = bps / 100; + return percent == percent.roundToDouble() ? percent.toStringAsFixed(0) : percent.toString(); +} diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 8227b0976..52d267b3d 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -1,15 +1,20 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart'; -import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; -import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/token_icon.dart'; import 'package:resonance_network_wallet/v2/screens/swap/refund_address_picker_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/swap/review_quote_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; import 'package:resonance_network_wallet/v2/screens/swap/token_picker_sheet.dart'; class SwapScreen extends ConsumerStatefulWidget { @@ -24,38 +29,17 @@ class _SwapScreenState extends ConsumerState { static const _historyIconAsset = 'assets/v2/swap_clock_counter_clockwise.svg'; static const _swapDirectionIconAsset = 'assets/v2/swap_arrows_down_up.svg'; - final _swapService = SwapService(); final _fromController = TextEditingController(); final _addressController = TextEditingController(); - SwapToken _fromToken = SwapService.availableTokens.first; - double _toAmount = 0; - double _fromUsd = 0; - double _toUsd = 0; - bool _loading = false; - - double get _rate => _swapService.getRate(_fromToken); - - String _rateLabel(AppLocalizations l10n) { - final val = 1 / _rate; - if (val == 0) return l10n.swapRateZero(_fromToken.symbol, AppConstants.tokenSymbol); - final decimals = val >= 100 - ? 2 - : val >= 1 - ? 4 - : val >= 0.01 - ? 6 - : val >= 0.0001 - ? 8 - : 10; - var formatted = val.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); - if (formatted.endsWith('.')) formatted = formatted.substring(0, formatted.length - 1); - return l10n.swapRateLabel(formatted, _fromToken.symbol, AppConstants.tokenSymbol); - } + SwapToken? _fromToken; + bool _loadingTokens = true; + bool _quoting = false; @override void initState() { super.initState(); - _fromController.addListener(_recalculate); + _fromController.addListener(() => setState(() {})); + _loadTokens(); } @override @@ -65,48 +49,83 @@ class _SwapScreenState extends ConsumerState { super.dispose(); } - void _recalculate() { - final amount = double.tryParse(_fromController.text) ?? 0; - setState(() { - _toAmount = amount * _rate; - _fromUsd = amount * _swapService.getUsdPrice(_fromToken); - _toUsd = _toAmount * _swapService.getUsdPrice(_swapService.getQuToken()); - }); + Future _loadTokens({bool forceRefresh = false}) async { + setState(() => _loadingTokens = true); + try { + final tokens = await ref.read(swapServiceProvider).getFromTokens(forceRefresh: forceRefresh); + if (!mounted) return; + setState(() => _fromToken ??= tokens.first); + } catch (e) { + quantusPrint('Swap tokens failed to load: $e'); + } finally { + if (mounted) setState(() => _loadingTokens = false); + } + } + + BigInt _amountIn(SwapToken from) => + ref.read(numberFormattingServiceProvider).parseAmount(_fromController.text, decimals: from.decimals) ?? + BigInt.zero; + + static double _usd(BigInt amount, SwapToken token) => amount.toDouble() / pow(10, token.decimals) * token.usdPrice; + + static BigInt _estimateOut(BigInt amountIn, SwapToken from, SwapToken to) { + if (to.usdPrice <= 0) return BigInt.zero; + return BigInt.from(_usd(amountIn, from) / to.usdPrice * pow(10, to.decimals)); } - bool get _canGetQuote => - _fromController.text.isNotEmpty && - (double.tryParse(_fromController.text) ?? 0) > 0 && - _addressController.text.isNotEmpty; + static String _amountHint(SwapToken token) => token.decimals == 0 ? '0' : '0.${'0' * token.decimals.clamp(1, 8)}'; - Future _getQuote() async { - final amount = double.tryParse(_fromController.text) ?? 0; - if (amount <= 0 || _addressController.text.isEmpty) return; + String _rateLabel(AppLocalizations l10n, SwapToken from, SwapToken to) { + if (from.usdPrice <= 0 || to.usdPrice <= 0) return l10n.swapRateZero(from.symbol, to.symbol); + final val = to.usdPrice / from.usdPrice; + final decimals = val >= 100 + ? 2 + : val >= 1 + ? 4 + : val >= 0.01 + ? 6 + : val >= 0.0001 + ? 8 + : 10; + var formatted = val.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); + if (formatted.endsWith('.')) formatted = formatted.substring(0, formatted.length - 1); + return l10n.swapRateLabel(formatted, from.symbol, to.symbol); + } - setState(() => _loading = true); + Future _getQuote(SwapToken from, SwapToken to) async { + final refund = _addressController.text.trim(); + final recipient = ref.read(activeAccountProvider).value!.account.accountId; + final service = ref.read(swapServiceProvider); + setState(() => _quoting = true); try { - final quote = await _swapService.getQuote(fromToken: _fromToken, fromAmount: amount); + final quote = await service.getQuote( + from: from, + to: to, + amount: _amountIn(from), + refundAddress: refund, + recipient: recipient, + ); + await service.addRefundAddress(from.network, refund); if (!mounted) return; - setState(() => _loading = false); - _swapService.addRefundAddress(_fromToken.network, _addressController.text.trim()); - showReviewQuoteSheet(context, quote, _addressController.text); + showReviewQuoteSheet(context, quote); } catch (e) { quantusPrint('Swap quote failed: $e'); - setState(() => _loading = false); + if (!mounted) return; + context.showErrorToaster(message: ref.read(l10nProvider).swapQuoteError(describeSwapError(e))); + } finally { + if (mounted) setState(() => _quoting = false); } } - void _pickToken() async { + void _pickToken(SwapToken from) async { + final service = ref.read(swapServiceProvider); final token = await showTokenPickerSheet( context, - current: _fromToken, - loadTokens: ({bool forceRefresh = false}) => _swapService.getFromTokens(limit: 10, forceRefresh: forceRefresh), + current: from, + loadTokens: ({bool forceRefresh = false}) => service.getFromTokens(limit: 10, forceRefresh: forceRefresh), ); if (!mounted) return; - if (token != null && token != _fromToken) { - setState(() => _fromToken = token); - _recalculate(); - } + if (token != null && token != from) setState(() => _fromToken = token); } void _scanQr() async { @@ -122,42 +141,74 @@ class _SwapScreenState extends ConsumerState { final l10n = ref.watch(l10nProvider); final colors = context.colorsV3; final text = context.themeTextV3; + final from = _fromToken; + final to = ref.watch(quantusSwapTokenProvider); return ScaffoldBase( appBar: V2AppBar( title: l10n.swapTitle, trailing: Icon(Icons.info_outline, color: colors.textContent, size: 24), ), - mainContent: Column( + mainContent: from == null ? _tokensState(l10n, colors, text) : _form(l10n, colors, text, from, to), + ); + } + + Widget _tokensState(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { + if (_loadingTokens) return const Center(child: Loader()); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 24), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _fromSection(l10n, colors, text), - const SizedBox(height: 32), - _refundAddressSection(l10n, colors, text), - const SizedBox(height: 32), - _swapDivider(colors), - const SizedBox(height: 32), - _toSection(l10n, colors, text), - const SizedBox(height: 32), - _infoSection(l10n, colors, text), - ], - ), - ), + Text(l10n.swapTokenPickerLoadError, style: text.body.copyWith(color: colors.textMuted)), + const SizedBox(height: 12), + GestureDetector( + onTap: () => _loadTokens(forceRefresh: true), + child: Text(l10n.commonTryAgain, style: text.body.copyWith(color: colors.accentFlare)), ), - const SizedBox(height: 16), - _quoteButton(l10n), - const SizedBox(height: 24), ], ), ); } - Widget _fromSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _form(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, SwapToken to) { + final amountIn = _amountIn(from); + final estimateOut = _estimateOut(amountIn, from, to); + final enabled = amountIn > BigInt.zero && _addressController.text.trim().isNotEmpty && !_quoting; + return Column( + children: [ + const SizedBox(height: 24), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _fromSection(l10n, colors, text, from, amountIn), + const SizedBox(height: 32), + _refundAddressSection(l10n, colors, text, from), + const SizedBox(height: 32), + _swapDivider(colors), + const SizedBox(height: 32), + _toSection(l10n, colors, text, to, estimateOut), + const SizedBox(height: 32), + _infoSection(l10n, colors, text, from, to), + ], + ), + ), + ), + const SizedBox(height: 16), + QuantusButton.simple( + label: l10n.swapGetQuote, + onTap: () => _getQuote(from, to), + isDisabled: !enabled, + isLoading: _quoting, + variant: ButtonVariant.staged, + ), + const SizedBox(height: 24), + ], + ); + } + + Widget _fromSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, BigInt amountIn) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -176,7 +227,7 @@ class _SwapScreenState extends ConsumerState { style: text.amountHero.copyWith(color: colors.textContent), keyboardType: const TextInputType.numberWithOptions(decimal: true), decoration: InputDecoration( - hintText: SwapService.formatTokenAmountHint(_fromToken), + hintText: _amountHint(from), hintStyle: text.amountHero.copyWith(color: colors.textMuted2), border: InputBorder.none, isDense: true, @@ -194,24 +245,24 @@ class _SwapScreenState extends ConsumerState { child: QuantusButton( centered: false, variant: ButtonVariant.glass, - onTap: _pickToken, + onTap: () => _pickToken(from), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), borderRadius: context.radiusV3.mdBorder, child: Row( children: [ - TokenIcon(token: _fromToken, size: 25, networkBadgeSize: 10), + TokenIcon(token: from, size: 25, networkBadgeSize: 10), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - _fromToken.symbol, + from.symbol, style: text.caption.copyWith(color: colors.textContent), overflow: TextOverflow.ellipsis, ), Text( - _fromToken.network, + from.network, style: text.caption.copyWith(color: colors.textMuted), overflow: TextOverflow.ellipsis, ), @@ -229,7 +280,7 @@ class _SwapScreenState extends ConsumerState { const SizedBox(height: 4), Row( children: [ - Text('\$${_fromUsd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), + Text('\$${_usd(amountIn, from).toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), const SizedBox(width: 4), QuantusIcon(QuantusIcons.swapVertical, color: colors.textMuted, size: 12), ], @@ -238,7 +289,7 @@ class _SwapScreenState extends ConsumerState { ); } - Widget _refundAddressSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _refundAddressSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -260,7 +311,7 @@ class _SwapScreenState extends ConsumerState { controller: _addressController, style: text.dataAddressLarge.copyWith(color: colors.textContent), decoration: InputDecoration( - hintText: l10n.swapRefundAddressHint(_fromToken.network), + hintText: l10n.swapRefundAddressHint(from.network), hintStyle: text.dataAddressLarge.copyWith(color: colors.textMuted), border: InputBorder.none, isDense: true, @@ -278,7 +329,7 @@ class _SwapScreenState extends ConsumerState { colors: colors, iconAsset: _historyIconAsset, onTap: () async { - final address = await showRefundAddressPickerSheet(context, _fromToken.network); + final address = await showRefundAddressPickerSheet(context, from.network); if (address != null) { _addressController.text = address; setState(() {}); @@ -327,7 +378,8 @@ class _SwapScreenState extends ConsumerState { ); } - Widget _toSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _toSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken to, BigInt estimateOut) { + final fmt = ref.watch(numberFormattingServiceProvider); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -342,8 +394,10 @@ class _SwapScreenState extends ConsumerState { decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder), alignment: Alignment.centerLeft, child: Text( - SwapService.formatTokenAmount(_toAmount, _swapService.getQuToken()), - style: text.amountHero.copyWith(color: _toAmount > 0 ? colors.textContent : colors.textMuted2), + fmt.formatAmount(estimateOut, decimals: to.decimals), + style: text.amountHero.copyWith( + color: estimateOut > BigInt.zero ? colors.textContent : colors.textMuted2, + ), ), ), ), @@ -360,9 +414,9 @@ class _SwapScreenState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - TokenIcon(token: _swapService.getQuToken(), size: 25, networkBadgeSize: 10), + TokenIcon(token: to, size: 25, networkBadgeSize: 10), const SizedBox(width: 8), - Text(AppConstants.tokenSymbol, style: text.body.copyWith(color: colors.textContent)), + Text(to.symbol, style: text.body.copyWith(color: colors.textContent)), ], ), ), @@ -370,12 +424,12 @@ class _SwapScreenState extends ConsumerState { ], ), const SizedBox(height: 4), - Text('\$${_toUsd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), + Text('\$${_usd(estimateOut, to).toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), ], ); } - Widget _infoSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text) { + Widget _infoSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, SwapToken to) { return Column( children: [ Row( @@ -384,7 +438,10 @@ class _SwapScreenState extends ConsumerState { Text(l10n.swapSlippageTolerance, style: text.caption.copyWith(color: colors.textMuted)), Row( children: [ - Text('1%', style: text.caption.copyWith(color: colors.textMuted)), + Text( + '${slippagePercentLabel(SwapService.defaultSlippageBps)}%', + style: text.caption.copyWith(color: colors.textMuted), + ), const SizedBox(width: 4), Icon(Icons.settings, color: colors.textMuted, size: 12), ], @@ -396,20 +453,10 @@ class _SwapScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(l10n.swapRate, style: text.caption.copyWith(color: colors.textMuted)), - Text(_rateLabel(l10n), style: text.caption.copyWith(color: colors.textMuted)), + Text(_rateLabel(l10n, from, to), style: text.caption.copyWith(color: colors.textMuted)), ], ), ], ); } - - Widget _quoteButton(AppLocalizations l10n) { - final enabled = _canGetQuote && !_loading; - return QuantusButton.simple( - label: l10n.swapGetQuote, - onTap: _getQuote, - isDisabled: !enabled, - variant: ButtonVariant.staged, - ); - } } From d53db06b72443359b3862ce26bfd67e14e9fdccb Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Mon, 21 Sep 2026 00:44:25 +0800 Subject: [PATCH 03/11] Document how NEAR Intents swaps work --- mobile-app/docs/near-intents-swaps.md | 143 ++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 mobile-app/docs/near-intents-swaps.md diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md new file mode 100644 index 000000000..dfd86855f --- /dev/null +++ b/mobile-app/docs/near-intents-swaps.md @@ -0,0 +1,143 @@ +# NEAR Intents swaps + +How the wallet swaps another chain's token into QTC through the NEAR Intents 1Click API, who sends what, and who waits on whom. + +Verified against the live API on 2026-09-20. There is no testnet for NEAR Intents. + +## What NEAR Intents is + +An intent is a signed statement of an outcome: "I give X of asset A on chain P and want at least Y of asset B on chain Q." Solvers, which are market makers, compete to fill it. The fill settles on NEAR inside the Intents Verifier contract, and bridged representations of the assets (`nep141:*.omft.near`) move in and out over NEAR's bridges. + +The 1Click API (`https://1click.chaindefuser.com`) is the REST front door to all of that. It runs the price auction, hands out one deposit address per quote, watches the origin chain, drives execution, pays out and refunds. The wallet only talks to 1Click. It never signs or holds any of the swapped funds. + +## Actors + +| Actor | Where it runs | What it does | +| --- | --- | --- | +| User | Their own origin-chain wallet (MetaMask, Phantom, a BTC wallet...) | Types the amount and a refund address, then sends the deposit from that wallet. | +| Quantus wallet | The app, `SwapService` in `quantus_sdk` | Asks 1Click for tokens, quotes and status. Passes the active account as the recipient. | +| 1Click API | NEAR Intents infrastructure | Auction, deposit addresses, chain watching, execution, payout, refunds, status. | +| Solvers | Off-chain market makers connected to 1Click | Price the quote and fill the intent. | +| Intents Verifier contract | NEAR | Settles the fill atomically. | +| Origin chain | ETH, BTC, SOL, BASE... (36 chains listed today) | Where the deposit lands and where refunds go. | +| Destination chain | Quantus | Where the payout would land. Not connected yet, see [Blockers](#blockers). | + +## The happy path + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant W as Quantus wallet + participant C as 1Click API + participant S as Solvers + participant O as Origin chain + participant N as NEAR Intents contract + participant D as Quantus chain + + W->>C: GET /v0/tokens + C-->>W: asset ids, decimals, USD prices + U->>W: amount, origin token, refund address + W->>C: POST /v0/quote (dry: true) + C->>S: request for quote + Note over C,S: 1Click waits up to quoteWaitingTimeMs (3 s) + S-->>C: best price + C-->>W: amountOut, minAmountOut, timeEstimate + U->>W: Confirm + W->>C: POST /v0/quote (dry: false) + C-->>W: depositAddress, deadline (now + 20 min) + Note over W: Deposit screen: QR + address, polls status every 5 s + U->>O: sends amountIn to depositAddress from an external wallet + loop every 5 s until SUCCESS, REFUNDED or FAILED + W->>C: GET /v0/status?depositAddress=... + C-->>W: PENDING_DEPOSIT → KNOWN_DEPOSIT_TX → PROCESSING → SUCCESS + end + O-->>C: deposit confirmed + C->>N: publish intent, solver fills, settle + N-->>C: settled + C->>D: withdraw amountOut to recipient + C-->>W: SUCCESS, destination tx hashes +``` + +## Who waits on whom + +| Step | Who waits | On what | How long | What ends it | +| --- | --- | --- | --- | --- | +| Token list | App | 1Click | Under a second | Response. Cached for 10 minutes in the app. | +| Dry quote | App | 1Click, which waits on solvers | 1 to 5 s (`quoteWaitingTimeMs` is 3000) | Best solver price, or "Failed to get quote" if no solver bids. | +| Live quote | App | 1Click | Same as dry | Deposit address is reserved until `deadline`. | +| Deposit | 1Click, app polls | The user's origin-chain transaction | Up to the deadline, 20 minutes in our request | Deposit seen, or deadline passes. | +| Confirmation | 1Click | Origin-chain finality | Seconds on SOL, minutes on ETH, up to an hour on BTC. Docs say allow 15 min. | Enough confirmations. | +| Settlement | 1Click | Solver fill and NEAR finality | Seconds | Intent settled. | +| Payout | 1Click | Destination-chain withdrawal | Seconds to minutes | `SUCCESS` with `destinationChainTxHashes`. | + +The app never blocks on anything but its own HTTP calls. The user waits on the deposit screen, and it is safe for them to leave it: 1Click continues regardless. Losing the screen does lose the deposit address in the app, see [Blockers](#blockers). + +## Who sends what + +| Call | App sends | App reads back | +| --- | --- | --- | +| `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. QTC is dropped from the "from" list. | +| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance: 100` (1%), `originAsset`, `destinationAsset`, `amount` in base units, `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo`, `correlationId`. The request is echoed back in `quoteRequest`. | +| `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `destinationChainTxHashes[].hash`. | + +Amounts are strings of base units in both directions. The wallet parses user input with the origin token's decimals and formats every amount with its own token's decimals. + +Errors come back as `{"message": ...}` with a 4xx status. The app shows the message as is. Seen live: `refundTo is not valid`, `tokenOut is not valid`, `Failed to get quote` for an amount too small to fill, and 404 `Deposit address ... not found` on status. + +## Swap status + +```mermaid +stateDiagram-v2 + [*] --> PENDING_DEPOSIT: live quote + PENDING_DEPOSIT --> KNOWN_DEPOSIT_TX: deposit tx seen + PENDING_DEPOSIT --> INCOMPLETE_DEPOSIT: less than amountIn arrived + PENDING_DEPOSIT --> Expired: deadline passes, nothing arrived + KNOWN_DEPOSIT_TX --> PROCESSING: confirmed + PROCESSING --> SUCCESS: filled and paid out + PROCESSING --> REFUNDED: no fill, price moved past slippage, or deadline + PROCESSING --> FAILED: error + INCOMPLETE_DEPOSIT --> REFUNDED: after deadline + SUCCESS --> [*] + REFUNDED --> [*] + FAILED --> [*] +``` + +`Expired` is not a 1Click status. 1Click keeps reporting `PENDING_DEPOSIT` after the deadline; the app shows an expired view when the deadline has passed and nothing arrived, and keeps polling in case a late deposit turns into a refund. + +## Refunds + +- Less than `amountIn` arrives: refunded to `refundTo` after the deadline, minus `refundFee` (0.30 USDC on ETH in the live probe). +- More than `amountIn` arrives: the swap executes and the excess goes to `refundTo`. +- No solver can fill, or the price moves past the slippage: `REFUNDED`. +- 1Click validates `refundTo` against the origin chain at quote time, so a bad refund address never reaches the deposit step. + +## Fees + +- Without a partner key 1Click adds 25 basis points to every quote. The live probe echoed `appFees: [{recipient: 5880ad2b..., fee: 25}]` on a request that sent none. +- With a partner key from partners.near-intents.org the platform fee is 20 basis points, 1 basis point on stablecoin and same-asset routes. Pass it as `SwapService(apiKey:)`; it goes out as `X-API-Key`. +- Fees are inside `amountOut`. Nothing is charged on top of `amountIn`. +- `refundFee` and `withdrawFee` in the quote are in base units of the origin asset. + +## Where this lives in the wallet + +| Screen or class | File | 1Click call | +| --- | --- | --- | +| `SwapScreen` | `mobile-app/lib/v2/screens/swap/swap_screen.dart` | Tokens on open. Dry quote on "Get a Quote", recipient is the active account. | +| `ReviewQuoteSheet` | `mobile-app/lib/v2/screens/swap/review_quote_sheet.dart` | Live quote on Confirm, then opens the deposit screen with the order. | +| `DepositScreen` | `mobile-app/lib/v2/screens/swap/deposit_screen.dart` | Status every 5 s. One view per status, plus the expired view. | +| `SwapService` | `quantus_sdk/lib/src/services/swap_service.dart` | The HTTP client. `SwapApiException` carries the server message. | +| `SwapToken`, `SwapQuote`, `SwapOrder` | `quantus_sdk/lib/src/models/swap_*.dart` | Asset ids and BigInt base-unit amounts. | +| Endpoints | `quantus_sdk/lib/src/constants/app_constants.dart` | `oneClickEndpoint`, `quantusIntentsAssetId`. | + +Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request shape, response parsing, every status, and error handling against a mock HTTP client. + +## Blockers + +1. **QTC is not listed on NEAR Intents.** The token list has 196 assets on 36 chains and none is Quantus. `AppConstants.quantusIntentsAssetId` is a placeholder, and every quote into it fails with `tokenOut is not valid`. Listing needs NEAR Intents to bridge the Quantus chain; that is a conversation with the NEAR Intents team, not a code change here. +2. **No partner key**, so every quote carries the extra 25 basis points. +3. **Orders are not persisted.** If the deposit screen is closed, the app has no record of the deposit address. The user still has it in the wallet they sent from, and 1Click keeps processing. +4. `POST /v0/deposit/submit` is not used. It only speeds up detection by handing 1Click the deposit tx hash. +5. The home swap button stays behind `AppConstants.showSwapButton = false`. +6. Only `EXACT_INPUT` with origin-chain deposits and destination-chain payout. Signed-intent execution would need the wallet to sign NEP-413 or similar payloads. +7. Slippage is fixed at 1%. From fee19c171f7d3211edd7991b05f990416a1780c9 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 25 Sep 2026 22:45:34 +0800 Subject: [PATCH 04/11] Remove the mainnet migration debug picker Debug builds no longer show the migration notice on every launch with a testnet outcome picker. The notice now only shows when it is due, in debug and release alike. Drops AppConstants.debugMainnetMigration, the forced-outcome provider and the picker's test. --- .../v2/swap_clock_counter_clockwise.svg | 5 - .../providers/mainnet_migration_provider.dart | 23 +-- .../v2/screens/swap/review_quote_sheet.dart | 160 ------------------ ...t.dart => saved_address_picker_sheet.dart} | 0 ..._screen.dart => swap_progress_screen.dart} | 0 .../welcome/mainnet_migration_screen.dart | 36 +--- .../mainnet_migration_screen_test.dart | 27 +-- .../lib/src/constants/app_constants.dart | 5 - 8 files changed, 6 insertions(+), 250 deletions(-) delete mode 100644 mobile-app/assets/v2/swap_clock_counter_clockwise.svg delete mode 100644 mobile-app/lib/v2/screens/swap/review_quote_sheet.dart rename mobile-app/lib/v2/screens/swap/{refund_address_picker_sheet.dart => saved_address_picker_sheet.dart} (100%) rename mobile-app/lib/v2/screens/swap/{deposit_screen.dart => swap_progress_screen.dart} (100%) diff --git a/mobile-app/assets/v2/swap_clock_counter_clockwise.svg b/mobile-app/assets/v2/swap_clock_counter_clockwise.svg deleted file mode 100644 index b1c5d03ea..000000000 --- a/mobile-app/assets/v2/swap_clock_counter_clockwise.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/mobile-app/lib/providers/mainnet_migration_provider.dart b/mobile-app/lib/providers/mainnet_migration_provider.dart index 92f1c5879..72809e7c5 100644 --- a/mobile-app/lib/providers/mainnet_migration_provider.dart +++ b/mobile-app/lib/providers/mainnet_migration_provider.dart @@ -1,6 +1,4 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_riverpod/legacy.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/services/mainnet_migration_service.dart'; @@ -13,26 +11,15 @@ final mainnetMigrationServiceProvider = Provider( ), ); -/// Debug builds show the notice on every launch; its checking page then picks -/// the testnet outcome at runtime, no real testnet data needed. -final debugMainnetMigrationProvider = Provider((_) => kDebugMode && AppConstants.debugMainnetMigration); - -/// A picked outcome stands in for the testnet check; null runs the real one. -final forcedTestnetOutcomeProvider = StateProvider((_) => null); - -const debugTestnetOutcomes = ['miner', 'holder', 'newcomer', 'error']; - /// Whether the notice is still due. final mainnetMigrationPendingProvider = Provider((ref) { if (AppConstants.runOnTestnet) return false; - return ref.watch(debugMainnetMigrationProvider) || ref.watch(mainnetMigrationServiceProvider).isPending(); + return ref.watch(mainnetMigrationServiceProvider).isPending(); }); /// The one-shot check is not retried: the unreachable page is written for it, /// and a retry would flip it back to the checking page. final testnetStatusProvider = FutureProvider((ref) async { - final forced = ref.watch(forcedTestnetOutcomeProvider); - if (forced != null) return _forcedStatus(forced); try { return await ref.watch(mainnetMigrationServiceProvider).checkTestnetStatus(); } catch (e) { @@ -40,11 +27,3 @@ final testnetStatusProvider = FutureProvider((ref) async { rethrow; } }, retry: (_, _) => null); - -TestnetStatus _forcedStatus(String outcome) => switch (outcome) { - 'miner' => TestnetStatus(blocksMined: 1234, balance: BigInt.zero), - 'holder' => TestnetStatus(blocksMined: 0, balance: BigInt.from(7) * BigInt.from(10).pow(AppConstants.decimals)), - 'newcomer' => TestnetStatus(blocksMined: 0, balance: BigInt.zero), - 'error' => throw Exception('debugMainnetMigration: testnet unreachable'), - _ => throw ArgumentError.value(outcome, 'debugMainnetMigration'), -}; diff --git a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart b/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart deleted file mode 100644 index d417cbba6..000000000 --- a/mobile-app/lib/v2/screens/swap/review_quote_sheet.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/shared/utils/print.dart'; -import 'package:resonance_network_wallet/v2/components/token_icon.dart'; -import 'package:resonance_network_wallet/v2/screens/swap/deposit_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; - -void showReviewQuoteSheet(BuildContext context, SwapQuote quote) { - BottomSheetContainer.show(context, builder: (_) => _ReviewQuoteContent(quote: quote)); -} - -class _ReviewQuoteContent extends ConsumerStatefulWidget { - final SwapQuote quote; - const _ReviewQuoteContent({required this.quote}); - - @override - ConsumerState<_ReviewQuoteContent> createState() => _ReviewQuoteContentState(); -} - -class _ReviewQuoteContentState extends ConsumerState<_ReviewQuoteContent> { - bool _confirming = false; - - SwapQuote get quote => widget.quote; - - Future _confirm() async { - final navigator = Navigator.of(context); - setState(() => _confirming = true); - try { - final order = await ref.read(swapServiceProvider).createSwap(quote); - if (!mounted) return; - navigator.pop(); - navigator.push(MaterialPageRoute(builder: (_) => DepositScreen(order: order))); - } catch (e) { - quantusPrint('Swap create failed: $e'); - if (!mounted) return; - context.showErrorToaster(message: ref.read(l10nProvider).swapQuoteError(describeSwapError(e))); - } finally { - if (mounted) setState(() => _confirming = false); - } - } - - String _timeLabel(AppLocalizations l10n) { - final seconds = quote.timeEstimate.inSeconds; - return seconds < 60 ? l10n.swapReviewTimeSeconds(seconds) : l10n.swapReviewTimeMinutes((seconds / 60).ceil()); - } - - @override - Widget build(BuildContext context) { - final l10n = ref.watch(l10nProvider); - final colors = context.colorsV3; - final text = context.themeTextV3; - final fmt = ref.watch(numberFormattingServiceProvider); - final to = quote.toToken; - - return BottomSheetContainer( - title: l10n.swapReviewTitle, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _swapVisual(context, fmt, colors, text), - const SizedBox(height: 48), - _row( - l10n.swapReviewMinimumReceived, - '${fmt.formatAmount(quote.minAmountOut, decimals: to.decimals)} ${to.symbol}', - colors, - text, - highlight: true, - ), - const Padding(padding: EdgeInsets.symmetric(vertical: 16), child: MenuDivider()), - _row(l10n.swapReviewEstimatedTime, _timeLabel(l10n), colors, text), - const SizedBox(height: 24), - Text( - l10n.swapReviewSlippageWarning(slippagePercentLabel(quote.slippageBps)), - style: text.caption.copyWith(color: colors.textMuted), - ), - const SizedBox(height: 24), - QuantusButton.simple( - label: l10n.swapReviewConfirm, - variant: ButtonVariant.staged, - onTap: _confirm, - isLoading: _confirming, - ), - ], - ), - ); - } - - Widget _swapVisual(BuildContext context, NumberFormattingService fmt, AppColorsV3 colors, AppTextThemeV3 text) { - final cardWidth = MediaQuery.of(context).size.width / 3; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _tokenCard(context, quote.fromToken, quote.amountIn, quote.amountInUsd, cardWidth, fmt, colors, text), - Icon(Icons.arrow_forward, color: colors.textMuted, size: 20), - _tokenCard(context, quote.toToken, quote.amountOut, quote.amountOutUsd, cardWidth, fmt, colors, text), - ], - ); - } - - Widget _tokenCard( - BuildContext context, - SwapToken token, - BigInt amount, - double usd, - double width, - NumberFormattingService fmt, - AppColorsV3 colors, - AppTextThemeV3 text, - ) { - return Container( - width: width, - height: 111, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - decoration: BoxDecoration(color: colors.bgSurface2, borderRadius: context.radiusV3.mdBorder), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - TokenIcon(token: token, size: 22, networkBadgeSize: 9), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(token.symbol, style: text.caption.copyWith(color: colors.textContent)), - Text(token.network, style: text.caption.copyWith(color: colors.textMuted)), - ], - ), - ], - ), - const SizedBox(height: 6), - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - fmt.formatAmount(amount, decimals: token.decimals), - style: text.amountRow.copyWith(color: colors.textContent), - ), - ), - Text('\$${usd.toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), - ], - ), - ); - } - - Widget _row(String label, String value, AppColorsV3 colors, AppTextThemeV3 text, {bool highlight = false}) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: text.caption.copyWith(color: colors.textMuted)), - Text(value, style: text.body.copyWith(color: highlight ? colors.textContent : colors.textMuted)), - ], - ); - } -} diff --git a/mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart similarity index 100% rename from mobile-app/lib/v2/screens/swap/refund_address_picker_sheet.dart rename to mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart diff --git a/mobile-app/lib/v2/screens/swap/deposit_screen.dart b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart similarity index 100% rename from mobile-app/lib/v2/screens/swap/deposit_screen.dart rename to mobile-app/lib/v2/screens/swap/swap_progress_screen.dart diff --git a/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart b/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart index e2c2628ec..4c0acb77f 100644 --- a/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart +++ b/mobile-app/lib/v2/screens/welcome/mainnet_migration_screen.dart @@ -34,8 +34,8 @@ class _MainnetMigrationScreenState extends ConsumerState final _pages = PageController(); final _checkingShown = Future.delayed(_checkingMinimum); - /// Debug builds hold the checking page until an outcome is picked. - late bool _armed = !ref.read(debugMainnetMigrationProvider); + /// Turns to the outcome page once, when the check first settles. + bool _armed = true; bool _creating = false; @override @@ -52,12 +52,6 @@ class _MainnetMigrationScreenState extends ConsumerState if (mounted) await _turnTo(1); } - void _pickOutcome(String? outcome) { - ref.read(forcedTestnetOutcomeProvider.notifier).state = outcome; - ref.invalidate(testnetStatusProvider); - _armed = true; - } - Future _keep(TestnetUserKind? kind) async { try { await ref.read(mainnetMigrationServiceProvider).markDone(); @@ -114,7 +108,7 @@ class _MainnetMigrationScreenState extends ConsumerState controller: _pages, physics: const NeverScrollableScrollPhysics(), children: [ - _CheckingPage(l10n: l10n, onPickOutcome: ref.watch(debugMainnetMigrationProvider) ? _pickOutcome : null), + _CheckingPage(l10n: l10n), _OutcomePage( l10n: l10n, status: status, @@ -130,9 +124,8 @@ class _MainnetMigrationScreenState extends ConsumerState class _CheckingPage extends StatelessWidget { final AppLocalizations l10n; - final ValueChanged? onPickOutcome; - const _CheckingPage({required this.l10n, required this.onPickOutcome}); + const _CheckingPage({required this.l10n}); @override Widget build(BuildContext context) { @@ -156,27 +149,6 @@ class _CheckingPage extends StatelessWidget { ), const SizedBox(height: 16), Text(l10n.mainnetMigrationChecking, style: text.bodyLarge.copyWith(color: colors.textMuted)), - if (onPickOutcome != null) ...[ - const SizedBox(height: 24), - Text('DEBUG: pick the testnet outcome', style: text.caption.copyWith(color: colors.textMuted)), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final outcome in [...debugTestnetOutcomes, null]) - IntrinsicWidth( - child: QuantusButton.simple( - label: outcome ?? 'real', - onTap: () => onPickOutcome!(outcome), - variant: ButtonVariant.staged, - width: null, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - ], - ), - ], const SizedBox(height: 52), LinearProgressIndicator( minHeight: 4, diff --git a/mobile-app/test/screens/mainnet_migration_screen_test.dart b/mobile-app/test/screens/mainnet_migration_screen_test.dart index bbc4d259d..a98d8e6f6 100644 --- a/mobile-app/test/screens/mainnet_migration_screen_test.dart +++ b/mobile-app/test/screens/mainnet_migration_screen_test.dart @@ -27,12 +27,6 @@ class _Settings extends FakeSettingsService { } } -/// Never answers, so only a picked outcome can move the debug flow along. -class _SilentService extends Fake implements MainnetMigrationService { - @override - Future checkTestnetStatus() => Completer().future; -} - final _miner = TestnetStatus(blocksMined: 1234, balance: BigInt.zero); final _holder = TestnetStatus(blocksMined: 0, balance: BigInt.from(7)); final _newcomer = TestnetStatus(blocksMined: 0, balance: BigInt.zero); @@ -53,7 +47,6 @@ void main() { WidgetTester tester, Future Function() status, { bool failWrite = false, - bool debugPicker = false, }) async { final settings = _Settings()..failWrite = failWrite; final finished = []; @@ -61,11 +54,7 @@ void main() { MainnetMigrationScreen(onFinished: () => finished.add(true)), overrides: [ settingsServiceProvider.overrideWithValue(settings), - debugMainnetMigrationProvider.overrideWithValue(debugPicker), - if (debugPicker) - mainnetMigrationServiceProvider.overrideWithValue(_SilentService()) - else - testnetStatusProvider.overrideWith((ref) => status()), + testnetStatusProvider.overrideWith((ref) => status()), ], ); await tester.pump(); @@ -186,18 +175,4 @@ void main() { expect(find.text('READING TESTNET HISTORY'), findsOneWidget); expect(keep, findsNothing); }); - - testWidgets('debug builds hold the checking page until an outcome is picked', (tester) async { - await pumpFlow(tester, () async => _miner, debugPicker: true); - await tester.pump(const Duration(seconds: 3)); - expect(keep, findsNothing); - for (final outcome in debugTestnetOutcomes) { - expect(find.text(outcome), findsOneWidget); - } - - await tester.tap(find.text('error')); - await tester.pump(); - await turnPage(tester); - expect(find.text("We couldn't check this wallet."), findsOneWidget); - }); } diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index 3038ccf22..ffe990765 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -113,11 +113,6 @@ class AppConstants { // Always show the home backup nudge regardless of viewed state and balance static const bool debugAlwaysShowBackupNudge = false; - // Shows the one-time mainnet migration notice on every debug launch, with an - // outcome picker on its checking page so every flow can be walked through - // without real testnet data. Flip to false to see the notice only when due. - static const bool debugMainnetMigration = true; - // Shows an outcome picker on the Airdrop entry screen in debug builds so // every rewards state can be walked through without a mining wallet. static const bool debugMiningRewards = true; From 357907a6ec281a0511c2706274cf7d4d184b1718 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 25 Sep 2026 22:45:34 +0800 Subject: [PATCH 05/11] Support swaps out of QTC in the SDK and keep full token precision SwapToken knows whether it is the Quantus token and names its network ("Ethereum" for ETH). SwapOrder reads the origin chain transaction hashes. SwapService tells 1Click which transaction paid a deposit address (POST /v0/deposit/submit) and keeps saved addresses per network instead of recent refund addresses. LocaleNumberConfig.parseDecimal sizes its input cap by the token's decimals, so a 24-decimal amount parses instead of returning null. NumberFormattingService.formatExactAmount prints every significant digit for amounts someone must send exactly. QuantusTextField can keep room for a trailing widget wider than one icon button. --- quantus_sdk/lib/src/models/swap_order.dart | 10 ++++- quantus_sdk/lib/src/models/swap_token.dart | 30 +++++++++++++ .../src/services/locale_number_config.dart | 14 +++--- .../services/number_formatting_service.dart | 6 ++- .../lib/src/services/swap_service.dart | 36 +++++++++------ .../src/ui/components/quantus_text_field.dart | 7 ++- .../test/services/swap_service_test.dart | 44 ++++++++++++++++++- 7 files changed, 122 insertions(+), 25 deletions(-) diff --git a/quantus_sdk/lib/src/models/swap_order.dart b/quantus_sdk/lib/src/models/swap_order.dart index 95d51e448..8fdd2e391 100644 --- a/quantus_sdk/lib/src/models/swap_order.dart +++ b/quantus_sdk/lib/src/models/swap_order.dart @@ -25,6 +25,7 @@ class SwapOrder { final BigInt? amountOut; final BigInt? refundedAmount; final String? refundReason; + final List originTxHashes; final List destinationTxHashes; const SwapOrder({ @@ -33,6 +34,7 @@ class SwapOrder { this.amountOut, this.refundedAmount, this.refundReason, + this.originTxHashes = const [], this.destinationTxHashes = const [], }); @@ -45,14 +47,18 @@ class SwapOrder { return raw == null || raw.isEmpty ? null : BigInt.parse(raw); } - final destination = details?['destinationChainTxHashes'] as List? ?? const []; + List hashes(String key) => [ + for (final tx in details?[key] as List? ?? const []) (tx as Map)['hash'] as String, + ]; + return SwapOrder( quote: quote, status: SwapStatus.parse(json['status'] as String), amountOut: amount('amountOut'), refundedAmount: amount('refundedAmount'), refundReason: details?['refundReason'] as String?, - destinationTxHashes: [for (final tx in destination) (tx as Map)['hash'] as String], + originTxHashes: hashes('originChainTxHashes'), + destinationTxHashes: hashes('destinationChainTxHashes'), ); } } diff --git a/quantus_sdk/lib/src/models/swap_token.dart b/quantus_sdk/lib/src/models/swap_token.dart index 284f11f40..db5283dcc 100644 --- a/quantus_sdk/lib/src/models/swap_token.dart +++ b/quantus_sdk/lib/src/models/swap_token.dart @@ -1,4 +1,29 @@ +import 'package:quantus_sdk/src/constants/app_constants.dart'; + class SwapToken { + static const _networkNames = { + 'ARB': 'Arbitrum', + 'AVAX': 'Avalanche', + 'BASE': 'Base', + 'BCH': 'Bitcoin Cash', + 'BERA': 'Berachain', + 'BSC': 'BNB Chain', + 'BTC': 'Bitcoin', + 'CARDANO': 'Cardano', + 'DOGE': 'Dogecoin', + 'ETH': 'Ethereum', + 'GNOSIS': 'Gnosis', + 'LTC': 'Litecoin', + 'MONAD': 'Monad', + 'OP': 'Optimism', + 'POL': 'Polygon', + 'SOL': 'Solana', + 'STELLAR': 'Stellar', + 'SUI': 'Sui', + 'TRON': 'Tron', + 'ZEC': 'Zcash', + }; + final String assetId; final String symbol; final String network; @@ -17,6 +42,11 @@ class SwapToken { this.networkIconUrl, }); + bool get isQuantus => assetId == AppConstants.quantusIntentsAssetId; + + /// Human name of [network], e.g. "Ethereum" for ETH; the code itself when unknown. + String get networkName => _networkNames[network] ?? network; + SwapToken copyWith({String? iconUrl, String? networkIconUrl}) => SwapToken( assetId: assetId, symbol: symbol, diff --git a/quantus_sdk/lib/src/services/locale_number_config.dart b/quantus_sdk/lib/src/services/locale_number_config.dart index 446e14d8a..8b8a6a7b5 100644 --- a/quantus_sdk/lib/src/services/locale_number_config.dart +++ b/quantus_sdk/lib/src/services/locale_number_config.dart @@ -85,10 +85,12 @@ class LocaleNumberConfig { /// Hard cap on raw input length, applied before normalization so it must /// admit locale grouping: the widest legitimate amount is /// [AppConstants.maxWholeDigits] whole digits, two grouping separators, one - /// decimal separator, and [AppConstants.decimals] fractional digits - /// (`20,999,999.999999999999`). External input is attacker-controlled — - /// reject anything wider before any parsing work happens. - static const int maxInputLength = AppConstants.maxWholeDigits + 2 + 1 + AppConstants.decimals; + /// decimal separator, and [decimals] fractional digits + /// (`20,999,999.999999999999` for the chain's own token). External input is + /// attacker-controlled — reject anything wider before any parsing work happens. + static int maxInputLengthFor(int decimals) => AppConstants.maxWholeDigits + 2 + 1 + decimals; + + static final int maxInputLength = maxInputLengthFor(AppConstants.decimals); /// The only numeric shape we accept after normalization: digits with an /// optional single decimal separator, plus the mid-typing `.5` form. @@ -108,8 +110,8 @@ class LocaleNumberConfig { /// Throws [InvalidNumberInputException] when the input cannot be parsed. /// Empty input also throws — callers that want to treat empty as zero should /// short-circuit before calling. - Decimal parseDecimal(String input) { - if (input.length > maxInputLength) { + Decimal parseDecimal(String input, {int decimals = AppConstants.decimals}) { + if (input.length > maxInputLengthFor(decimals)) { throw InvalidNumberInputException(rawInput: input, normalized: input); } final normalized = normalize(input); diff --git a/quantus_sdk/lib/src/services/number_formatting_service.dart b/quantus_sdk/lib/src/services/number_formatting_service.dart index 398a05157..b929aca8a 100644 --- a/quantus_sdk/lib/src/services/number_formatting_service.dart +++ b/quantus_sdk/lib/src/services/number_formatting_service.dart @@ -32,6 +32,10 @@ class NumberFormattingService { String formatAmount(BigInt amount, {int decimals = AppConstants.decimals}) => formatBalance(amount, decimals: decimals, smartDecimals: 4, maxDecimals: decimals); + /// Every significant digit of [amount], for an amount someone must send exactly. + String formatExactAmount(BigInt amount, {int decimals = AppConstants.decimals}) => + formatBalance(amount, decimals: decimals, smartDecimals: decimals, maxDecimals: decimals); + /// A whole count with the locale's grouping separators, e.g. 5279 -> "5,279". String formatInteger(int value) => _localeConfig.localize(value.toString()); @@ -148,7 +152,7 @@ class NumberFormattingService { } try { - final decimalAmount = _localeConfig.parseDecimal(formattedAmount); + final decimalAmount = _localeConfig.parseDecimal(formattedAmount, decimals: decimals); if (decimalAmount.scale > decimals) { quantusPrint('Warning: Input amount $formattedAmount exceeds $decimals decimals, will be truncated.'); } diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index 456f12eb3..dce483925 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -28,8 +28,8 @@ class SwapService { static const depositWindow = Duration(minutes: 20); static const quoteWaitingTime = Duration(seconds: 3); static const statusPollInterval = Duration(seconds: 5); - static const _refundAddressKey = 'recent_refund_addresses'; - static const _maxRefundAddresses = 50; + static const _savedAddressesKey = 'swap_saved_addresses'; + static const _maxSavedAddresses = 50; static const _coinGeckoTopUrl = 'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=150&page=1&sparkline=false'; static const _tokensCacheTtl = Duration(minutes: 10); @@ -113,6 +113,17 @@ class SwapService { return SwapOrder(quote: live, status: SwapStatus.pendingDeposit); } + /// Tells 1Click which transaction paid [order]'s deposit address, so it + /// does not have to wait for its own chain scan to notice the deposit. + Future submitDeposit(SwapOrder order, String txHash) async { + final memo = order.quote.depositMemo; + await _send( + 'POST', + '/v0/deposit/submit', + body: {'txHash': txHash, 'depositAddress': order.depositAddress, 'memo': ?memo}, + ); + } + Future getSwapStatus(SwapOrder order) async { final memo = order.quote.depositMemo; final json = await _send( @@ -271,21 +282,18 @@ class SwapService { } } - Future addRefundAddress(String network, String address) async { + /// Remembers [address] on [network] for future swaps, most recent first. + Future saveAddress(String network, String address) async { final prefs = await SharedPreferences.getInstance(); - final key = '${_refundAddressKey}_${network.toLowerCase()}'; - var addresses = prefs.getStringList(key) ?? []; - addresses.remove(address); - addresses.insert(0, address); - if (addresses.length > _maxRefundAddresses) { - addresses = addresses.sublist(0, _maxRefundAddresses); - } - await prefs.setStringList(key, addresses); + final key = _savedAddressesStorageKey(network); + final addresses = [address, ...?prefs.getStringList(key)?.where((a) => a != address)]; + await prefs.setStringList(key, addresses.take(_maxSavedAddresses).toList()); } - Future> getRefundAddresses(String network) async { + Future> getSavedAddresses(String network) async { final prefs = await SharedPreferences.getInstance(); - final key = '${_refundAddressKey}_${network.toLowerCase()}'; - return prefs.getStringList(key) ?? []; + return prefs.getStringList(_savedAddressesStorageKey(network)) ?? []; } + + static String _savedAddressesStorageKey(String network) => '${_savedAddressesKey}_${network.toLowerCase()}'; } diff --git a/quantus_sdk/lib/src/ui/components/quantus_text_field.dart b/quantus_sdk/lib/src/ui/components/quantus_text_field.dart index 6d9f27d54..5ca30f180 100644 --- a/quantus_sdk/lib/src/ui/components/quantus_text_field.dart +++ b/quantus_sdk/lib/src/ui/components/quantus_text_field.dart @@ -18,6 +18,9 @@ class QuantusTextField extends StatefulWidget { final bool autocorrect; final ValueChanged? onSubmitted; final Widget? trailing; + + /// Width kept clear for a [trailing] that is not a single [QuantusIconButton]. + final double? trailingWidth; final int? maxLines; final bool expands; final double? height; @@ -39,6 +42,7 @@ class QuantusTextField extends StatefulWidget { this.autocorrect = true, this.onSubmitted, this.trailing, + this.trailingWidth, this.maxLines = 1, this.expands = false, this.height, @@ -97,7 +101,8 @@ class _QuantusTextFieldState extends State { void _rebuild() => setState(() {}); /// Keep the editable area clear of the trailing widget, whatever its size. - double _trailingReserve(Widget extra) => 4 + (extra is QuantusIconButton ? extra.buttonSize : 36); + double _trailingReserve(Widget extra) => + 4 + (extra is QuantusIconButton ? extra.buttonSize : widget.trailingWidth ?? 36); Color _borderColor(AppColorsV3 colors) { if (_hasError) return colors.semanticEmber.useOpacity(0.55); diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index 1261e22bc..c0ca8cbdc 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:shared_preferences/shared_preferences.dart'; const _usdcEth = SwapToken( assetId: 'nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near', @@ -216,7 +217,7 @@ void main() { } }); - test('reads the settled amounts, refund reason and destination hashes', () async { + test('reads the settled amounts, refund reason and transaction hashes', () async { final service = _service( (_) async => http.Response( jsonEncode( @@ -225,6 +226,9 @@ void main() { details: { 'amountOut': '2850000000000000000000000', 'refundedAmount': '', + 'originChainTxHashes': [ + {'hash': '0xorigin', 'explorerUrl': 'https://x/0xorigin'}, + ], 'destinationChainTxHashes': [ {'hash': '0xabc', 'explorerUrl': 'https://x/0xabc'}, ], @@ -237,6 +241,7 @@ void main() { final updated = await service.getSwapStatus(order); expect(updated.amountOut, BigInt.parse('2850000000000000000000000')); expect(updated.refundedAmount, isNull); + expect(updated.originTxHashes, ['0xorigin']); expect(updated.destinationTxHashes, ['0xabc']); final refunded = await _service( @@ -251,6 +256,18 @@ void main() { expect(refunded.refundReason, 'Quote expired'); }); + test('submits the deposit transaction hash for its deposit address', () async { + late http.Request request; + final service = _service((r) async { + request = r; + return http.Response(jsonEncode(_statusResponse('KNOWN_DEPOSIT_TX')), 200); + }); + await service.submitDeposit(order, '0xtx'); + expect(request.method, 'POST'); + expect(request.url.toString(), 'https://oneclick.test/v0/deposit/submit'); + expect(jsonDecode(request.body), {'txHash': '0xtx', 'depositAddress': '0xdeposit'}); + }); + test('rejects a status it does not know', () async { final service = _service((_) async => http.Response(jsonEncode(_statusResponse('SOMETHING_NEW')), 200)); await expectLater(service.getSwapStatus(order), throwsA(isA())); @@ -267,6 +284,31 @@ void main() { }); }); + group('saved addresses', () { + test('keeps addresses per network, most recent first, without duplicates', () async { + SharedPreferences.setMockInitialValues({}); + final service = _service((_) async => throw StateError('no HTTP expected')); + await service.saveAddress('ETH', '0xa'); + await service.saveAddress('ETH', '0xb'); + await service.saveAddress('ETH', '0xa'); + await service.saveAddress('SOL', 'sol1'); + expect(await service.getSavedAddresses('ETH'), ['0xa', '0xb']); + expect(await service.getSavedAddresses('SOL'), ['sol1']); + expect(await service.getSavedAddresses('BTC'), isEmpty); + }); + }); + + group('SwapToken', () { + test('names known networks and recognises the Quantus token', () { + expect(_usdcEth.networkName, 'Ethereum'); + expect(_wnear.networkName, 'NEAR'); + expect(_usdcEth.isQuantus, isFalse); + final quantus = SwapService.quantusToken(usdPrice: 1); + expect(quantus.isQuantus, isTrue); + expect(quantus.networkName, 'Quantus'); + }); + }); + group('SwapStatus', () { test('only success, refunded and failed are final', () { expect(SwapStatus.values.where((s) => s.isFinal), [SwapStatus.success, SwapStatus.refunded, SwapStatus.failed]); From 0f3848e0c8acac2cdf41954fc62934130eac5462 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 25 Sep 2026 22:45:34 +0800 Subject: [PATCH 06/11] Swap in both directions from one form, following the Figma swap out flow The swap form holds QTC in the account on one side and a token on another chain on the other; the arrows flip the direction. An address sheet asks for the recipient (swap out) or refund address (swap in), with saved addresses and QR scan, and quotes the swap. Review Swap is a full screen with rate, address, network fee, slippage allowance and guaranteed minimum. The slippage tolerance is picked from 0.5, 1, 2 and 3 percent. Confirming takes a live quote. When it guarantees less than the one reviewed, the new terms replace the old and need a second confirm. Swapping out, the app then signs a transfer of the QTC to the deposit address with the account's own key, using the regular send path, and hands the hash to 1Click. One progress screen follows the order: the deposit address while a swap in waits, then the steps, a details sheet, and Swap Complete or Swap Failed. The deposit amount shows at full precision. Swap is enabled only for transparent accounts that sign in the app. --- mobile-app/assets/v2/swap_address_book.svg | 5 + mobile-app/assets/v2/swap_arrow_down.svg | 5 + mobile-app/assets/v2/swap_list.svg | 5 + mobile-app/assets/v2/swap_pencil.svg | 5 + mobile-app/assets/v2/token_qtc.svg | 9 + mobile-app/lib/l10n/app_en.arb | 422 ++++++++---- mobile-app/lib/l10n/app_id.arb | 91 ++- mobile-app/lib/l10n/app_localizations.dart | 386 ++++++++--- mobile-app/lib/l10n/app_localizations_en.dart | 201 ++++-- mobile-app/lib/l10n/app_localizations_id.dart | 201 ++++-- .../lib/shared/utils/account_utils.dart | 3 + mobile-app/lib/v2/components/token_icon.dart | 13 + .../lib/v2/screens/home/home_screen.dart | 10 +- .../screens/send/regular_send_strategy.dart | 49 +- .../v2/screens/swap/review_swap_screen.dart | 202 ++++++ .../swap/saved_address_picker_sheet.dart | 23 +- .../v2/screens/swap/swap_address_sheet.dart | 165 +++++ .../v2/screens/swap/swap_progress_screen.dart | 652 +++++++++++------- .../lib/v2/screens/swap/swap_providers.dart | 63 +- .../lib/v2/screens/swap/swap_screen.dart | 600 ++++++++-------- .../v2/screens/swap/swap_slippage_sheet.dart | 47 ++ .../lib/v2/screens/swap/swap_summary.dart | 131 ++++ mobile-app/pubspec.yaml | 6 +- mobile-app/test/screens/swap_flow_test.dart | 337 +++++++++ .../unit/locale_number_handling_test.dart | 11 + 25 files changed, 2675 insertions(+), 967 deletions(-) create mode 100644 mobile-app/assets/v2/swap_address_book.svg create mode 100644 mobile-app/assets/v2/swap_arrow_down.svg create mode 100644 mobile-app/assets/v2/swap_list.svg create mode 100644 mobile-app/assets/v2/swap_pencil.svg create mode 100644 mobile-app/assets/v2/token_qtc.svg create mode 100644 mobile-app/lib/v2/screens/swap/review_swap_screen.dart create mode 100644 mobile-app/lib/v2/screens/swap/swap_address_sheet.dart create mode 100644 mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart create mode 100644 mobile-app/lib/v2/screens/swap/swap_summary.dart create mode 100644 mobile-app/test/screens/swap_flow_test.dart diff --git a/mobile-app/assets/v2/swap_address_book.svg b/mobile-app/assets/v2/swap_address_book.svg new file mode 100644 index 000000000..18383ead0 --- /dev/null +++ b/mobile-app/assets/v2/swap_address_book.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/swap_arrow_down.svg b/mobile-app/assets/v2/swap_arrow_down.svg new file mode 100644 index 000000000..8f7ecf1a8 --- /dev/null +++ b/mobile-app/assets/v2/swap_arrow_down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/swap_list.svg b/mobile-app/assets/v2/swap_list.svg new file mode 100644 index 000000000..c638e943c --- /dev/null +++ b/mobile-app/assets/v2/swap_list.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/swap_pencil.svg b/mobile-app/assets/v2/swap_pencil.svg new file mode 100644 index 000000000..7daf86b4c --- /dev/null +++ b/mobile-app/assets/v2/swap_pencil.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/mobile-app/assets/v2/token_qtc.svg b/mobile-app/assets/v2/token_qtc.svg new file mode 100644 index 000000000..980f1566f --- /dev/null +++ b/mobile-app/assets/v2/token_qtc.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 3a89b5cd8..0e3b09f79 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -2302,65 +2302,69 @@ }, "swapFrom": "From", "@swapFrom": { - "description": "From token section label on swap screen" + "description": "Label of the side the swapped funds come from" }, "swapTo": "To", "@swapTo": { - "description": "To token section label on swap screen" + "description": "Label of the side the swapped funds go to" }, - "swapRefundAddress": "Refund Address", - "@swapRefundAddress": { - "description": "Refund address field label on swap screen" - }, - "swapRefundAddressHint": "{network} Address", - "@swapRefundAddressHint": { - "description": "Refund address field hint", - "placeholders": { - "network": { - "type": "String" - } - } - }, - "swapSlippageTolerance": "Slippage Tolerance", - "@swapSlippageTolerance": { - "description": "Slippage tolerance label on swap screen" + "swapExternalWallet": "A wallet you control", + "@swapExternalWallet": { + "description": "Owner of the external token side of the swap form" }, "swapRate": "Rate", "@swapRate": { "description": "Exchange rate label on swap screen" }, - "swapGetQuote": "Get a Quote", - "@swapGetQuote": { - "description": "Get quote button on swap screen" - }, - "swapRateLabel": "1 {tokenSymbol} = {amount} {symbol}", + "swapRateLabel": "1 {fromSymbol} = {rate} {toSymbol}", "@swapRateLabel": { - "description": "Exchange rate display", + "description": "Exchange rate: what one input token buys", "placeholders": { - "amount": { + "fromSymbol": { "type": "String" }, - "symbol": { + "rate": { "type": "String" }, - "tokenSymbol": { + "toSymbol": { "type": "String" } } }, - "swapRateZero": "1 {tokenSymbol} = 0 {symbol}", - "@swapRateZero": { - "description": "Exchange rate when amount is zero", + "swapSlippageLabel": "Slippage {percent}%", + "@swapSlippageLabel": { + "description": "Slippage tolerance under the swap form", "placeholders": { - "symbol": { + "percent": { "type": "String" - }, - "tokenSymbol": { + } + } + }, + "swapSlippageTitle": "Slippage Tolerance", + "@swapSlippageTitle": { + "description": "Title of the slippage tolerance sheet" + }, + "swapSlippageBody": "If the price moves further than this before your swap fills, it is refunded instead. A higher tolerance fills more often but can pay out less.", + "@swapSlippageBody": { + "description": "Explanation on the slippage tolerance sheet" + }, + "swapSlippagePercent": "{percent}%", + "@swapSlippagePercent": { + "description": "A slippage tolerance option", + "placeholders": { + "percent": { "type": "String" } } }, - + "swapAddRecipientAddress": "Add Recipient Address", + "@swapAddRecipientAddress": { + "description": "Swap form button for a swap out of QTC" + }, + "swapAddRefundAddress": "Add Refund Address", + "@swapAddRefundAddress": { + "description": "Swap form button for a swap into QTC" + }, "swapTokenPickerTitle": "Select Token", "@swapTokenPickerTitle": { "description": "Title on token picker sheet" @@ -2378,151 +2382,246 @@ } } }, - - "swapReviewTitle": "Review Quote", - "@swapReviewTitle": { - "description": "Title on review quote sheet" + "swapRecipientAddressTitle": "Recipient Address", + "@swapRecipientAddressTitle": { + "description": "Title of the sheet asking where the swapped funds go" }, - "swapReviewMinimumReceived": "Minimum received", - "@swapReviewMinimumReceived": { - "description": "Least amount the quote guarantees, on review quote sheet" + "swapRefundAddressTitle": "Refund Address", + "@swapRefundAddressTitle": { + "description": "Title of the sheet asking where a failed swap is refunded" }, - "swapReviewEstimatedTime": "Estimated time", - "@swapReviewEstimatedTime": { - "description": "Row label for how long 1Click expects the swap to take" + "swapAddressLabel": "{symbol} Address", + "@swapAddressLabel": { + "description": "Address field label in the swap address sheet", + "placeholders": { + "symbol": { + "type": "String" + } + } }, - "swapReviewTimeSeconds": "About {seconds} seconds", - "@swapReviewTimeSeconds": { - "description": "Swap time estimate under a minute", + "swapAddressHint": "{network} Address", + "@swapAddressHint": { + "description": "Address field hint in the swap address sheet", "placeholders": { - "seconds": { - "type": "int" + "network": { + "type": "String" } } }, - "swapReviewTimeMinutes": "About {minutes} minutes", - "@swapReviewTimeMinutes": { - "description": "Swap time estimate of a minute or more", + "swapRecipientAddressNotice": "Your {symbol} will arrive at this address. Double-check it, swaps cannot be reversed.", + "@swapRecipientAddressNotice": { + "description": "Warning under the recipient address field", "placeholders": { - "minutes": { - "type": "int" + "symbol": { + "type": "String" } } }, - "swapReviewSlippageWarning": "The rate locks in when your deposit arrives. With the {percent}% slippage you set, you receive at least the minimum above.", - "@swapReviewSlippageWarning": { - "description": "Slippage note on review quote sheet", + "swapRefundAddressNotice": "If the swap cannot complete, your {symbol} returns to this address on {network}. Double-check it.", + "@swapRefundAddressNotice": { + "description": "Warning under the refund address field", "placeholders": { - "percent": { + "symbol": { + "type": "String" + }, + "network": { "type": "String" } } }, - "swapReviewConfirm": "Confirm", - "@swapReviewConfirm": { - "description": "Confirm button on review quote sheet" + "swapSaveAddress": "Save for future swaps", + "@swapSaveAddress": { + "description": "Checkbox that saves the entered address" }, - - "swapDepositAmount": "Deposit Amount", - "@swapDepositAmount": { - "description": "Deposit amount label on deposit screen" + "swapContinue": "Continue to Swap", + "@swapContinue": { + "description": "Button that quotes the swap with the entered address" }, - "swapDepositAmountCopied": "Deposit amount copied to clipboard", - "@swapDepositAmountCopied": { - "description": "Toast when deposit amount is copied" + "swapSavedAddressesTitle": "Saved Addresses", + "@swapSavedAddressesTitle": { + "description": "Title of the saved swap addresses sheet" }, - "swapDepositAddressCopied": "Deposit address copied to clipboard", - "@swapDepositAddressCopied": { - "description": "Toast when the deposit address is copied" + "swapSavedAddressesEmpty": "No saved addresses yet", + "@swapSavedAddressesEmpty": { + "description": "Empty saved swap addresses sheet" }, - "swapDepositWaiting": "Waiting for your deposit…", - "@swapDepositWaiting": { - "description": "Shown while 1Click has not seen a deposit yet" + "swapReviewTitle": "Review Swap", + "@swapReviewTitle": { + "description": "Title of the swap review screen" }, - "swapDepositDeadline": "Send before {time} or the quote expires", - "@swapDepositDeadline": { - "description": "Local time by which the deposit must arrive", - "placeholders": { - "time": { - "type": "String" - } - } + "swapYouPay": "You pay", + "@swapYouPay": { + "description": "Pay side of the swap review card" }, - "swapDepositShareQr": "Share QR", - "@swapDepositShareQr": { - "description": "Share QR button on deposit screen" + "swapYouReceive": "You receive", + "@swapYouReceive": { + "description": "Receive side of the swap review card" }, - "swapDepositShareContent": "Network: {network}\nToken: {token}\nAddress: {address}", - "@swapDepositShareContent": { - "description": "Share text for deposit details", + "swapReviewRecipient": "Recipient", + "@swapReviewRecipient": { + "description": "Review row: external address receiving the swap" + }, + "swapReviewRefundAddress": "Refund address", + "@swapReviewRefundAddress": { + "description": "Review row: external address a failed swap is refunded to" + }, + "swapReviewNetworkFee": "Network fee", + "@swapReviewNetworkFee": { + "description": "Review row and details row: chain fee of the QTC deposit" + }, + "swapReviewFeeUnavailable": "Unavailable", + "@swapReviewFeeUnavailable": { + "description": "Network fee value when it could not be fetched" + }, + "swapReviewFeeFailed": "Could not get the network fee.", + "@swapReviewFeeFailed": { + "description": "Error under the review rows when the fee failed" + }, + "swapReviewSlippage": "Slippage allowance", + "@swapReviewSlippage": { + "description": "Review row: how far below the quote the output may land" + }, + "swapReviewSlippageValue": "{amount} ({percent}%)", + "@swapReviewSlippageValue": { + "description": "Slippage allowance value", "placeholders": { - "network": { - "type": "String" - }, - "token": { + "amount": { "type": "String" }, - "address": { + "percent": { "type": "String" } } }, - "swapDepositNotice": "Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.", - "@swapDepositNotice": { - "description": "Deposit wallet notice on deposit screen", + "swapReviewGuaranteedMinimum": "Guaranteed minimum", + "@swapReviewGuaranteedMinimum": { + "description": "Review row: least the swap pays out" + }, + "swapReviewInsufficient": "Not enough {symbol} for this amount plus the network fee.", + "@swapReviewInsufficient": { + "description": "Review error when the balance cannot cover the deposit", "placeholders": { "symbol": { "type": "String" - }, - "network": { - "type": "String" } } }, - "swapDepositProcessingTitle": "Processing Swap", - "@swapDepositProcessingTitle": { - "description": "Title while swap is processing" + "swapReviewPriceMoved": "The price moved. Check the new amounts and confirm again.", + "@swapReviewPriceMoved": { + "description": "Toast when the live quote guarantees less than the one reviewed" + }, + "swapReviewConfirm": "Confirm swap", + "@swapReviewConfirm": { + "description": "Confirm button on the swap review screen" + }, + "swapInProgressTitle": "Swap In Progress", + "@swapInProgressTitle": { + "description": "Title while the swap is running" }, - "swapDepositProcessingBody": "This may take a few minutes...", - "@swapDepositProcessingBody": { - "description": "Body while swap is processing" + "swapYourePaying": "You're paying", + "@swapYourePaying": { + "description": "Pay side of the swap progress card" }, - "swapDepositCompleteTitle": "Swap Complete", - "@swapDepositCompleteTitle": { - "description": "Title when swap is complete" + "swapYoureReceiving": "You're receiving", + "@swapYoureReceiving": { + "description": "Receive side of the swap progress card" }, - "swapDepositCompleteBody": "Your swap for {amount} {tokenSymbol} is complete.", - "@swapDepositCompleteBody": { - "description": "Body when swap is complete", + "swapStepSent": "{symbol} sent", + "@swapStepSent": { + "description": "Progress step: deposit sent", "placeholders": { - "amount": { - "type": "String" - }, - "tokenSymbol": { + "symbol": { "type": "String" } } }, - "swapDepositDetectedBody": "Your deposit was seen on {network}. The swap starts once it is confirmed.", - "@swapDepositDetectedBody": { - "description": "Body while 1Click has seen the deposit transaction but not yet confirmed it", + "swapStepConfirming": "Confirming on {network}", + "@swapStepConfirming": { + "description": "Progress step: deposit confirming", "placeholders": { "network": { "type": "String" } } }, - "swapDepositRefundedTitle": "Swap Refunded", - "@swapDepositRefundedTitle": { - "description": "Title when 1Click refunded the deposit" + "swapStepSwapping": "Swapping", + "@swapStepSwapping": { + "description": "Progress step: solvers swapping" + }, + "swapStepSending": "Sending {symbol} to {recipient}", + "@swapStepSending": { + "description": "Progress step: payout", + "placeholders": { + "symbol": { + "type": "String" + }, + "recipient": { + "type": "String" + } + } + }, + "swapProgressFooter": "Usually a few minutes. The swap finishes even if you leave this screen. · via NEAR Intents", + "@swapProgressFooter": { + "description": "Footer under the swap progress bar" + }, + "swapDetailsTitle": "Swap Details", + "@swapDetailsTitle": { + "description": "Title of the swap details sheet" + }, + "swapDetailsRecipient": "Recipient address", + "@swapDetailsRecipient": { + "description": "Details row: address receiving the swap" + }, + "swapDetailsAmountSent": "Amount sent", + "@swapDetailsAmountSent": { + "description": "Details row: deposit amount" + }, + "swapDetailsExpected": "Expected", + "@swapDetailsExpected": { + "description": "Details row: quoted payout" }, - "swapDepositRefundedBody": "The swap did not complete. {amount} {symbol} was returned to your refund address.", - "@swapDepositRefundedBody": { - "description": "Body when 1Click refunded the deposit", + "swapDetailsTransaction": "Transaction", + "@swapDetailsTransaction": { + "description": "Details row: deposit transaction hash" + }, + "swapCompleteTitle": "Swap Complete", + "@swapCompleteTitle": { + "description": "Title when the swap paid out" + }, + "swapCompleteReceived": "Received", + "@swapCompleteReceived": { + "description": "Caption under the paid-out amount" + }, + "swapCompleteTo": "To:", + "@swapCompleteTo": { + "description": "Label above where the swap paid out" + }, + "swapFailedTitle": "Swap Failed", + "@swapFailedTitle": { + "description": "Title when the swap did not pay out" + }, + "swapRefundedLabel": "Refunded", + "@swapRefundedLabel": { + "description": "Status label on a refunded swap" + }, + "swapYouPaid": "You paid", + "@swapYouPaid": { + "description": "Pay side of a failed swap card" + }, + "swapYouWereReceiving": "You were receiving", + "@swapYouWereReceiving": { + "description": "Receive side of a failed swap card" + }, + "swapRefundedBody": "The swap could not be completed, so your {amount} has been returned to {destination}, minus the network fee. The {symbol} was never sent.", + "@swapRefundedBody": { + "description": "Banner on a refunded swap", "placeholders": { "amount": { "type": "String" }, + "destination": { + "type": "String" + }, "symbol": { "type": "String" } @@ -2537,10 +2636,6 @@ } } }, - "swapDepositFailedTitle": "Swap Failed", - "@swapDepositFailedTitle": { - "description": "Title when 1Click reports the swap failed" - }, "swapDepositFailedBody": "The swap did not complete. Contact support and quote this deposit address: {address}", "@swapDepositFailedBody": { "description": "Body when 1Click reports the swap failed", @@ -2566,18 +2661,69 @@ "@swapDepositExpiredBody": { "description": "Body when the deadline passed with no deposit" }, - "swapDepositDone": "Done", - "@swapDepositDone": { - "description": "Done button after swap completes" + "swapStartNew": "Start a New Swap", + "@swapStartNew": { + "description": "Button on a failed swap" }, - - "swapRefundPickerTitle": "Refund Addresses", - "@swapRefundPickerTitle": { - "description": "Title on refund address picker sheet" + "swapContactSupport": "Contact Support", + "@swapContactSupport": { + "description": "Link on a failed swap" + }, + "swapDepositAmount": "Deposit Amount", + "@swapDepositAmount": { + "description": "Deposit amount label on deposit screen" }, - "swapRefundPickerEmpty": "No recent refund addresses", - "@swapRefundPickerEmpty": { - "description": "Empty state on refund address picker" + "swapDepositAmountCopied": "Deposit amount copied to clipboard", + "@swapDepositAmountCopied": { + "description": "Toast when deposit amount is copied" + }, + "swapDepositAddressCopied": "Deposit address copied to clipboard", + "@swapDepositAddressCopied": { + "description": "Toast when the deposit address is copied" + }, + "swapDepositWaiting": "Waiting for your deposit…", + "@swapDepositWaiting": { + "description": "Shown while 1Click has not seen a deposit yet" + }, + "swapDepositDeadline": "Send before {time} or the quote expires", + "@swapDepositDeadline": { + "description": "Local time by which the deposit must arrive", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "swapDepositShareQr": "Share QR", + "@swapDepositShareQr": { + "description": "Share QR button on deposit screen" + }, + "swapDepositShareContent": "Network: {network}\nToken: {token}\nAddress: {address}", + "@swapDepositShareContent": { + "description": "Share text for deposit details", + "placeholders": { + "network": { + "type": "String" + }, + "token": { + "type": "String" + }, + "address": { + "type": "String" + } + } + }, + "swapDepositNotice": "Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.", + "@swapDepositNotice": { + "description": "Deposit wallet notice on deposit screen", + "placeholders": { + "symbol": { + "type": "String" + }, + "network": { + "type": "String" + } + } }, "componentQrScannerTitle": "Scan QR Code", diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 323b47b8a..40e382e48 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -448,23 +448,71 @@ "swapPoweredBy": "Didukung Oleh", "swapFrom": "Dari", "swapTo": "Ke", - "swapRefundAddress": "Alamat Refund", - "swapRefundAddressHint": "Alamat {network}", - "swapSlippageTolerance": "Toleransi Slippage", + "swapExternalWallet": "Dompet yang Anda kendalikan", "swapRate": "Kurs", - "swapGetQuote": "Dapatkan Penawaran", - "swapRateLabel": "1 {tokenSymbol} = {amount} {symbol}", - "swapRateZero": "1 {tokenSymbol} = 0 {symbol}", + "swapRateLabel": "1 {fromSymbol} = {rate} {toSymbol}", + "swapSlippageLabel": "Slippage {percent}%", + "swapSlippageTitle": "Toleransi Slippage", + "swapSlippageBody": "Jika harga bergerak lebih dari ini sebelum swap Anda terisi, dana dikembalikan. Toleransi lebih tinggi lebih sering terisi tetapi hasilnya bisa lebih sedikit.", + "swapSlippagePercent": "{percent}%", + "swapAddRecipientAddress": "Tambahkan Alamat Penerima", + "swapAddRefundAddress": "Tambahkan Alamat Refund", "swapTokenPickerTitle": "Pilih Token", "swapTokenPickerLoadError": "Gagal memuat token", "swapQuoteError": "Penawaran gagal: {message}", - "swapReviewTitle": "Tinjau Penawaran", - "swapReviewMinimumReceived": "Minimum diterima", - "swapReviewEstimatedTime": "Perkiraan waktu", - "swapReviewTimeSeconds": "Sekitar {seconds} detik", - "swapReviewTimeMinutes": "Sekitar {minutes} menit", - "swapReviewSlippageWarning": "Kurs dikunci saat deposit Anda tiba. Dengan slippage {percent}% yang Anda atur, Anda menerima setidaknya minimum di atas.", - "swapReviewConfirm": "Konfirmasi", + "swapRecipientAddressTitle": "Alamat Penerima", + "swapRefundAddressTitle": "Alamat Refund", + "swapAddressLabel": "Alamat {symbol}", + "swapAddressHint": "Alamat {network}", + "swapRecipientAddressNotice": "{symbol} Anda akan tiba di alamat ini. Periksa kembali, swap tidak dapat dibatalkan.", + "swapRefundAddressNotice": "Jika swap tidak dapat diselesaikan, {symbol} Anda dikembalikan ke alamat ini di {network}. Periksa kembali.", + "swapSaveAddress": "Simpan untuk swap berikutnya", + "swapContinue": "Lanjutkan ke Swap", + "swapSavedAddressesTitle": "Alamat Tersimpan", + "swapSavedAddressesEmpty": "Belum ada alamat tersimpan", + "swapReviewTitle": "Tinjau Swap", + "swapYouPay": "Anda bayar", + "swapYouReceive": "Anda terima", + "swapReviewRecipient": "Penerima", + "swapReviewRefundAddress": "Alamat refund", + "swapReviewNetworkFee": "Biaya jaringan", + "swapReviewFeeUnavailable": "Tidak tersedia", + "swapReviewFeeFailed": "Tidak dapat mengambil biaya jaringan.", + "swapReviewSlippage": "Toleransi slippage", + "swapReviewSlippageValue": "{amount} ({percent}%)", + "swapReviewGuaranteedMinimum": "Minimum terjamin", + "swapReviewInsufficient": "{symbol} tidak cukup untuk jumlah ini ditambah biaya jaringan.", + "swapReviewPriceMoved": "Harga berubah. Periksa jumlah baru dan konfirmasi lagi.", + "swapReviewConfirm": "Konfirmasi swap", + "swapInProgressTitle": "Swap Sedang Berjalan", + "swapYourePaying": "Anda membayar", + "swapYoureReceiving": "Anda menerima", + "swapStepSent": "{symbol} terkirim", + "swapStepConfirming": "Mengonfirmasi di {network}", + "swapStepSwapping": "Menukar", + "swapStepSending": "Mengirim {symbol} ke {recipient}", + "swapProgressFooter": "Biasanya beberapa menit. Swap tetap selesai meskipun Anda meninggalkan layar ini. · via NEAR Intents", + "swapDetailsTitle": "Detail Swap", + "swapDetailsRecipient": "Alamat penerima", + "swapDetailsAmountSent": "Jumlah dikirim", + "swapDetailsExpected": "Perkiraan", + "swapDetailsTransaction": "Transaksi", + "swapCompleteTitle": "Swap Selesai", + "swapCompleteReceived": "Diterima", + "swapCompleteTo": "Ke:", + "swapFailedTitle": "Swap Gagal", + "swapRefundedLabel": "Dikembalikan", + "swapYouPaid": "Anda telah membayar", + "swapYouWereReceiving": "Anda akan menerima", + "swapRefundedBody": "Swap tidak dapat diselesaikan, jadi {amount} Anda telah dikembalikan ke {destination}, dikurangi biaya jaringan. {symbol} tidak pernah dikirim.", + "swapDepositRefundReason": "Alasan: {reason}", + "swapDepositFailedBody": "Swap tidak selesai. Hubungi dukungan dan sebutkan alamat deposit ini: {address}", + "swapDepositIncompleteTitle": "Deposit Terlalu Kecil", + "swapDepositIncompleteBody": "Jumlah yang tiba kurang dari penawaran. Dana akan dikembalikan ke alamat refund Anda setelah tenggat waktu.", + "swapDepositExpiredTitle": "Penawaran Kedaluwarsa", + "swapDepositExpiredBody": "Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.", + "swapStartNew": "Mulai Swap Baru", + "swapContactSupport": "Hubungi Dukungan", "swapDepositAmount": "Jumlah Deposit", "swapDepositAmountCopied": "Jumlah deposit disalin ke clipboard", "swapDepositAddressCopied": "Alamat deposit disalin ke clipboard", @@ -473,23 +521,6 @@ "swapDepositShareQr": "Bagikan QR", "swapDepositShareContent": "Jaringan: {network}\nToken: {token}\nAlamat: {address}", "swapDepositNotice": "Gunakan dompet {symbol} atau {network} Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.", - "swapDepositProcessingTitle": "Memproses Swap", - "swapDepositProcessingBody": "Ini mungkin memakan waktu beberapa menit...", - "swapDepositCompleteTitle": "Swap Selesai", - "swapDepositCompleteBody": "Swap Anda untuk {amount} {tokenSymbol} telah selesai.", - "swapDepositDetectedBody": "Deposit Anda terlihat di {network}. Swap dimulai setelah dikonfirmasi.", - "swapDepositRefundedTitle": "Swap Dikembalikan", - "swapDepositRefundedBody": "Swap tidak selesai. {amount} {symbol} dikembalikan ke alamat refund Anda.", - "swapDepositRefundReason": "Alasan: {reason}", - "swapDepositFailedTitle": "Swap Gagal", - "swapDepositFailedBody": "Swap tidak selesai. Hubungi dukungan dan sebutkan alamat deposit ini: {address}", - "swapDepositIncompleteTitle": "Deposit Terlalu Kecil", - "swapDepositIncompleteBody": "Jumlah yang tiba kurang dari penawaran. Dana akan dikembalikan ke alamat refund Anda setelah tenggat waktu.", - "swapDepositExpiredTitle": "Penawaran Kedaluwarsa", - "swapDepositExpiredBody": "Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.", - "swapDepositDone": "Selesai", - "swapRefundPickerTitle": "Alamat Refund", - "swapRefundPickerEmpty": "Tidak ada alamat refund terbaru", "componentQrScannerTitle": "Pindai Kode QR", "componentQrScannerNoCode": "Tidak ada kode QR pada gambar", "componentShare": "Bagikan", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index e9331b6ea..55c7bc155 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -3002,59 +3002,71 @@ abstract class AppLocalizations { /// **'Powered By'** String get swapPoweredBy; - /// From token section label on swap screen + /// Label of the side the swapped funds come from /// /// In en, this message translates to: /// **'From'** String get swapFrom; - /// To token section label on swap screen + /// Label of the side the swapped funds go to /// /// In en, this message translates to: /// **'To'** String get swapTo; - /// Refund address field label on swap screen + /// Owner of the external token side of the swap form /// /// In en, this message translates to: - /// **'Refund Address'** - String get swapRefundAddress; + /// **'A wallet you control'** + String get swapExternalWallet; - /// Refund address field hint + /// Exchange rate label on swap screen /// /// In en, this message translates to: - /// **'{network} Address'** - String swapRefundAddressHint(String network); + /// **'Rate'** + String get swapRate; + + /// Exchange rate: what one input token buys + /// + /// In en, this message translates to: + /// **'1 {fromSymbol} = {rate} {toSymbol}'** + String swapRateLabel(String fromSymbol, String rate, String toSymbol); + + /// Slippage tolerance under the swap form + /// + /// In en, this message translates to: + /// **'Slippage {percent}%'** + String swapSlippageLabel(String percent); - /// Slippage tolerance label on swap screen + /// Title of the slippage tolerance sheet /// /// In en, this message translates to: /// **'Slippage Tolerance'** - String get swapSlippageTolerance; + String get swapSlippageTitle; - /// Exchange rate label on swap screen + /// Explanation on the slippage tolerance sheet /// /// In en, this message translates to: - /// **'Rate'** - String get swapRate; + /// **'If the price moves further than this before your swap fills, it is refunded instead. A higher tolerance fills more often but can pay out less.'** + String get swapSlippageBody; - /// Get quote button on swap screen + /// A slippage tolerance option /// /// In en, this message translates to: - /// **'Get a Quote'** - String get swapGetQuote; + /// **'{percent}%'** + String swapSlippagePercent(String percent); - /// Exchange rate display + /// Swap form button for a swap out of QTC /// /// In en, this message translates to: - /// **'1 {tokenSymbol} = {amount} {symbol}'** - String swapRateLabel(String amount, String symbol, String tokenSymbol); + /// **'Add Recipient Address'** + String get swapAddRecipientAddress; - /// Exchange rate when amount is zero + /// Swap form button for a swap into QTC /// /// In en, this message translates to: - /// **'1 {tokenSymbol} = 0 {symbol}'** - String swapRateZero(String symbol, String tokenSymbol); + /// **'Add Refund Address'** + String get swapAddRefundAddress; /// Title on token picker sheet /// @@ -3074,149 +3086,281 @@ abstract class AppLocalizations { /// **'Quote failed: {message}'** String swapQuoteError(String message); - /// Title on review quote sheet + /// Title of the sheet asking where the swapped funds go + /// + /// In en, this message translates to: + /// **'Recipient Address'** + String get swapRecipientAddressTitle; + + /// Title of the sheet asking where a failed swap is refunded + /// + /// In en, this message translates to: + /// **'Refund Address'** + String get swapRefundAddressTitle; + + /// Address field label in the swap address sheet + /// + /// In en, this message translates to: + /// **'{symbol} Address'** + String swapAddressLabel(String symbol); + + /// Address field hint in the swap address sheet + /// + /// In en, this message translates to: + /// **'{network} Address'** + String swapAddressHint(String network); + + /// Warning under the recipient address field + /// + /// In en, this message translates to: + /// **'Your {symbol} will arrive at this address. Double-check it, swaps cannot be reversed.'** + String swapRecipientAddressNotice(String symbol); + + /// Warning under the refund address field + /// + /// In en, this message translates to: + /// **'If the swap cannot complete, your {symbol} returns to this address on {network}. Double-check it.'** + String swapRefundAddressNotice(String symbol, String network); + + /// Checkbox that saves the entered address + /// + /// In en, this message translates to: + /// **'Save for future swaps'** + String get swapSaveAddress; + + /// Button that quotes the swap with the entered address + /// + /// In en, this message translates to: + /// **'Continue to Swap'** + String get swapContinue; + + /// Title of the saved swap addresses sheet + /// + /// In en, this message translates to: + /// **'Saved Addresses'** + String get swapSavedAddressesTitle; + + /// Empty saved swap addresses sheet + /// + /// In en, this message translates to: + /// **'No saved addresses yet'** + String get swapSavedAddressesEmpty; + + /// Title of the swap review screen /// /// In en, this message translates to: - /// **'Review Quote'** + /// **'Review Swap'** String get swapReviewTitle; - /// Least amount the quote guarantees, on review quote sheet + /// Pay side of the swap review card /// /// In en, this message translates to: - /// **'Minimum received'** - String get swapReviewMinimumReceived; + /// **'You pay'** + String get swapYouPay; - /// Row label for how long 1Click expects the swap to take + /// Receive side of the swap review card /// /// In en, this message translates to: - /// **'Estimated time'** - String get swapReviewEstimatedTime; + /// **'You receive'** + String get swapYouReceive; - /// Swap time estimate under a minute + /// Review row: external address receiving the swap /// /// In en, this message translates to: - /// **'About {seconds} seconds'** - String swapReviewTimeSeconds(int seconds); + /// **'Recipient'** + String get swapReviewRecipient; - /// Swap time estimate of a minute or more + /// Review row: external address a failed swap is refunded to /// /// In en, this message translates to: - /// **'About {minutes} minutes'** - String swapReviewTimeMinutes(int minutes); + /// **'Refund address'** + String get swapReviewRefundAddress; - /// Slippage note on review quote sheet + /// Review row and details row: chain fee of the QTC deposit /// /// In en, this message translates to: - /// **'The rate locks in when your deposit arrives. With the {percent}% slippage you set, you receive at least the minimum above.'** - String swapReviewSlippageWarning(String percent); + /// **'Network fee'** + String get swapReviewNetworkFee; - /// Confirm button on review quote sheet + /// Network fee value when it could not be fetched /// /// In en, this message translates to: - /// **'Confirm'** + /// **'Unavailable'** + String get swapReviewFeeUnavailable; + + /// Error under the review rows when the fee failed + /// + /// In en, this message translates to: + /// **'Could not get the network fee.'** + String get swapReviewFeeFailed; + + /// Review row: how far below the quote the output may land + /// + /// In en, this message translates to: + /// **'Slippage allowance'** + String get swapReviewSlippage; + + /// Slippage allowance value + /// + /// In en, this message translates to: + /// **'{amount} ({percent}%)'** + String swapReviewSlippageValue(String amount, String percent); + + /// Review row: least the swap pays out + /// + /// In en, this message translates to: + /// **'Guaranteed minimum'** + String get swapReviewGuaranteedMinimum; + + /// Review error when the balance cannot cover the deposit + /// + /// In en, this message translates to: + /// **'Not enough {symbol} for this amount plus the network fee.'** + String swapReviewInsufficient(String symbol); + + /// Toast when the live quote guarantees less than the one reviewed + /// + /// In en, this message translates to: + /// **'The price moved. Check the new amounts and confirm again.'** + String get swapReviewPriceMoved; + + /// Confirm button on the swap review screen + /// + /// In en, this message translates to: + /// **'Confirm swap'** String get swapReviewConfirm; - /// Deposit amount label on deposit screen + /// Title while the swap is running /// /// In en, this message translates to: - /// **'Deposit Amount'** - String get swapDepositAmount; + /// **'Swap In Progress'** + String get swapInProgressTitle; - /// Toast when deposit amount is copied + /// Pay side of the swap progress card /// /// In en, this message translates to: - /// **'Deposit amount copied to clipboard'** - String get swapDepositAmountCopied; + /// **'You\'re paying'** + String get swapYourePaying; - /// Toast when the deposit address is copied + /// Receive side of the swap progress card /// /// In en, this message translates to: - /// **'Deposit address copied to clipboard'** - String get swapDepositAddressCopied; + /// **'You\'re receiving'** + String get swapYoureReceiving; - /// Shown while 1Click has not seen a deposit yet + /// Progress step: deposit sent /// /// In en, this message translates to: - /// **'Waiting for your deposit…'** - String get swapDepositWaiting; + /// **'{symbol} sent'** + String swapStepSent(String symbol); - /// Local time by which the deposit must arrive + /// Progress step: deposit confirming /// /// In en, this message translates to: - /// **'Send before {time} or the quote expires'** - String swapDepositDeadline(String time); + /// **'Confirming on {network}'** + String swapStepConfirming(String network); - /// Share QR button on deposit screen + /// Progress step: solvers swapping /// /// In en, this message translates to: - /// **'Share QR'** - String get swapDepositShareQr; + /// **'Swapping'** + String get swapStepSwapping; - /// Share text for deposit details + /// Progress step: payout /// /// In en, this message translates to: - /// **'Network: {network}\nToken: {token}\nAddress: {address}'** - String swapDepositShareContent(String network, String token, String address); + /// **'Sending {symbol} to {recipient}'** + String swapStepSending(String symbol, String recipient); - /// Deposit wallet notice on deposit screen + /// Footer under the swap progress bar /// /// In en, this message translates to: - /// **'Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.'** - String swapDepositNotice(String symbol, String network); + /// **'Usually a few minutes. The swap finishes even if you leave this screen. · via NEAR Intents'** + String get swapProgressFooter; - /// Title while swap is processing + /// Title of the swap details sheet /// /// In en, this message translates to: - /// **'Processing Swap'** - String get swapDepositProcessingTitle; + /// **'Swap Details'** + String get swapDetailsTitle; - /// Body while swap is processing + /// Details row: address receiving the swap /// /// In en, this message translates to: - /// **'This may take a few minutes...'** - String get swapDepositProcessingBody; + /// **'Recipient address'** + String get swapDetailsRecipient; - /// Title when swap is complete + /// Details row: deposit amount /// /// In en, this message translates to: - /// **'Swap Complete'** - String get swapDepositCompleteTitle; + /// **'Amount sent'** + String get swapDetailsAmountSent; - /// Body when swap is complete + /// Details row: quoted payout /// /// In en, this message translates to: - /// **'Your swap for {amount} {tokenSymbol} is complete.'** - String swapDepositCompleteBody(String amount, String tokenSymbol); + /// **'Expected'** + String get swapDetailsExpected; - /// Body while 1Click has seen the deposit transaction but not yet confirmed it + /// Details row: deposit transaction hash /// /// In en, this message translates to: - /// **'Your deposit was seen on {network}. The swap starts once it is confirmed.'** - String swapDepositDetectedBody(String network); + /// **'Transaction'** + String get swapDetailsTransaction; - /// Title when 1Click refunded the deposit + /// Title when the swap paid out /// /// In en, this message translates to: - /// **'Swap Refunded'** - String get swapDepositRefundedTitle; + /// **'Swap Complete'** + String get swapCompleteTitle; - /// Body when 1Click refunded the deposit + /// Caption under the paid-out amount /// /// In en, this message translates to: - /// **'The swap did not complete. {amount} {symbol} was returned to your refund address.'** - String swapDepositRefundedBody(String amount, String symbol); + /// **'Received'** + String get swapCompleteReceived; - /// Refund reason reported by 1Click + /// Label above where the swap paid out /// /// In en, this message translates to: - /// **'Reason: {reason}'** - String swapDepositRefundReason(String reason); + /// **'To:'** + String get swapCompleteTo; - /// Title when 1Click reports the swap failed + /// Title when the swap did not pay out /// /// In en, this message translates to: /// **'Swap Failed'** - String get swapDepositFailedTitle; + String get swapFailedTitle; + + /// Status label on a refunded swap + /// + /// In en, this message translates to: + /// **'Refunded'** + String get swapRefundedLabel; + + /// Pay side of a failed swap card + /// + /// In en, this message translates to: + /// **'You paid'** + String get swapYouPaid; + + /// Receive side of a failed swap card + /// + /// In en, this message translates to: + /// **'You were receiving'** + String get swapYouWereReceiving; + + /// Banner on a refunded swap + /// + /// In en, this message translates to: + /// **'The swap could not be completed, so your {amount} has been returned to {destination}, minus the network fee. The {symbol} was never sent.'** + String swapRefundedBody(String amount, String destination, String symbol); + + /// Refund reason reported by 1Click + /// + /// In en, this message translates to: + /// **'Reason: {reason}'** + String swapDepositRefundReason(String reason); /// Body when 1Click reports the swap failed /// @@ -3248,23 +3392,65 @@ abstract class AppLocalizations { /// **'No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.'** String get swapDepositExpiredBody; - /// Done button after swap completes + /// Button on a failed swap /// /// In en, this message translates to: - /// **'Done'** - String get swapDepositDone; + /// **'Start a New Swap'** + String get swapStartNew; - /// Title on refund address picker sheet + /// Link on a failed swap /// /// In en, this message translates to: - /// **'Refund Addresses'** - String get swapRefundPickerTitle; + /// **'Contact Support'** + String get swapContactSupport; - /// Empty state on refund address picker + /// Deposit amount label on deposit screen /// /// In en, this message translates to: - /// **'No recent refund addresses'** - String get swapRefundPickerEmpty; + /// **'Deposit Amount'** + String get swapDepositAmount; + + /// Toast when deposit amount is copied + /// + /// In en, this message translates to: + /// **'Deposit amount copied to clipboard'** + String get swapDepositAmountCopied; + + /// Toast when the deposit address is copied + /// + /// In en, this message translates to: + /// **'Deposit address copied to clipboard'** + String get swapDepositAddressCopied; + + /// Shown while 1Click has not seen a deposit yet + /// + /// In en, this message translates to: + /// **'Waiting for your deposit…'** + String get swapDepositWaiting; + + /// Local time by which the deposit must arrive + /// + /// In en, this message translates to: + /// **'Send before {time} or the quote expires'** + String swapDepositDeadline(String time); + + /// Share QR button on deposit screen + /// + /// In en, this message translates to: + /// **'Share QR'** + String get swapDepositShareQr; + + /// Share text for deposit details + /// + /// In en, this message translates to: + /// **'Network: {network}\nToken: {token}\nAddress: {address}'** + String swapDepositShareContent(String network, String token, String address); + + /// Deposit wallet notice on deposit screen + /// + /// In en, this message translates to: + /// **'Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.'** + String swapDepositNotice(String symbol, String network); /// Text for app bar or button label on QR scanner component /// diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index daa500c3c..823a55e7e 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1598,31 +1598,38 @@ class AppLocalizationsEn extends AppLocalizations { String get swapTo => 'To'; @override - String get swapRefundAddress => 'Refund Address'; + String get swapExternalWallet => 'A wallet you control'; @override - String swapRefundAddressHint(String network) { - return '$network Address'; + String get swapRate => 'Rate'; + + @override + String swapRateLabel(String fromSymbol, String rate, String toSymbol) { + return '1 $fromSymbol = $rate $toSymbol'; } @override - String get swapSlippageTolerance => 'Slippage Tolerance'; + String swapSlippageLabel(String percent) { + return 'Slippage $percent%'; + } @override - String get swapRate => 'Rate'; + String get swapSlippageTitle => 'Slippage Tolerance'; @override - String get swapGetQuote => 'Get a Quote'; + String get swapSlippageBody => + 'If the price moves further than this before your swap fills, it is refunded instead. A higher tolerance fills more often but can pay out less.'; @override - String swapRateLabel(String amount, String symbol, String tokenSymbol) { - return '1 $tokenSymbol = $amount $symbol'; + String swapSlippagePercent(String percent) { + return '$percent%'; } @override - String swapRateZero(String symbol, String tokenSymbol) { - return '1 $tokenSymbol = 0 $symbol'; - } + String get swapAddRecipientAddress => 'Add Recipient Address'; + + @override + String get swapAddRefundAddress => 'Add Refund Address'; @override String get swapTokenPickerTitle => 'Select Token'; @@ -1636,96 +1643,165 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get swapReviewTitle => 'Review Quote'; + String get swapRecipientAddressTitle => 'Recipient Address'; @override - String get swapReviewMinimumReceived => 'Minimum received'; + String get swapRefundAddressTitle => 'Refund Address'; @override - String get swapReviewEstimatedTime => 'Estimated time'; + String swapAddressLabel(String symbol) { + return '$symbol Address'; + } @override - String swapReviewTimeSeconds(int seconds) { - return 'About $seconds seconds'; + String swapAddressHint(String network) { + return '$network Address'; } @override - String swapReviewTimeMinutes(int minutes) { - return 'About $minutes minutes'; + String swapRecipientAddressNotice(String symbol) { + return 'Your $symbol will arrive at this address. Double-check it, swaps cannot be reversed.'; } @override - String swapReviewSlippageWarning(String percent) { - return 'The rate locks in when your deposit arrives. With the $percent% slippage you set, you receive at least the minimum above.'; + String swapRefundAddressNotice(String symbol, String network) { + return 'If the swap cannot complete, your $symbol returns to this address on $network. Double-check it.'; } @override - String get swapReviewConfirm => 'Confirm'; + String get swapSaveAddress => 'Save for future swaps'; @override - String get swapDepositAmount => 'Deposit Amount'; + String get swapContinue => 'Continue to Swap'; @override - String get swapDepositAmountCopied => 'Deposit amount copied to clipboard'; + String get swapSavedAddressesTitle => 'Saved Addresses'; @override - String get swapDepositAddressCopied => 'Deposit address copied to clipboard'; + String get swapSavedAddressesEmpty => 'No saved addresses yet'; @override - String get swapDepositWaiting => 'Waiting for your deposit…'; + String get swapReviewTitle => 'Review Swap'; @override - String swapDepositDeadline(String time) { - return 'Send before $time or the quote expires'; - } + String get swapYouPay => 'You pay'; @override - String get swapDepositShareQr => 'Share QR'; + String get swapYouReceive => 'You receive'; @override - String swapDepositShareContent(String network, String token, String address) { - return 'Network: $network\nToken: $token\nAddress: $address'; + String get swapReviewRecipient => 'Recipient'; + + @override + String get swapReviewRefundAddress => 'Refund address'; + + @override + String get swapReviewNetworkFee => 'Network fee'; + + @override + String get swapReviewFeeUnavailable => 'Unavailable'; + + @override + String get swapReviewFeeFailed => 'Could not get the network fee.'; + + @override + String get swapReviewSlippage => 'Slippage allowance'; + + @override + String swapReviewSlippageValue(String amount, String percent) { + return '$amount ($percent%)'; } @override - String swapDepositNotice(String symbol, String network) { - return 'Use your $symbol or $network wallet to deposit funds. Depositing other assets may result in loss of funds.'; + String get swapReviewGuaranteedMinimum => 'Guaranteed minimum'; + + @override + String swapReviewInsufficient(String symbol) { + return 'Not enough $symbol for this amount plus the network fee.'; } @override - String get swapDepositProcessingTitle => 'Processing Swap'; + String get swapReviewPriceMoved => 'The price moved. Check the new amounts and confirm again.'; @override - String get swapDepositProcessingBody => 'This may take a few minutes...'; + String get swapReviewConfirm => 'Confirm swap'; @override - String get swapDepositCompleteTitle => 'Swap Complete'; + String get swapInProgressTitle => 'Swap In Progress'; @override - String swapDepositCompleteBody(String amount, String tokenSymbol) { - return 'Your swap for $amount $tokenSymbol is complete.'; + String get swapYourePaying => 'You\'re paying'; + + @override + String get swapYoureReceiving => 'You\'re receiving'; + + @override + String swapStepSent(String symbol) { + return '$symbol sent'; } @override - String swapDepositDetectedBody(String network) { - return 'Your deposit was seen on $network. The swap starts once it is confirmed.'; + String swapStepConfirming(String network) { + return 'Confirming on $network'; } @override - String get swapDepositRefundedTitle => 'Swap Refunded'; + String get swapStepSwapping => 'Swapping'; @override - String swapDepositRefundedBody(String amount, String symbol) { - return 'The swap did not complete. $amount $symbol was returned to your refund address.'; + String swapStepSending(String symbol, String recipient) { + return 'Sending $symbol to $recipient'; } @override - String swapDepositRefundReason(String reason) { - return 'Reason: $reason'; + String get swapProgressFooter => + 'Usually a few minutes. The swap finishes even if you leave this screen. · via NEAR Intents'; + + @override + String get swapDetailsTitle => 'Swap Details'; + + @override + String get swapDetailsRecipient => 'Recipient address'; + + @override + String get swapDetailsAmountSent => 'Amount sent'; + + @override + String get swapDetailsExpected => 'Expected'; + + @override + String get swapDetailsTransaction => 'Transaction'; + + @override + String get swapCompleteTitle => 'Swap Complete'; + + @override + String get swapCompleteReceived => 'Received'; + + @override + String get swapCompleteTo => 'To:'; + + @override + String get swapFailedTitle => 'Swap Failed'; + + @override + String get swapRefundedLabel => 'Refunded'; + + @override + String get swapYouPaid => 'You paid'; + + @override + String get swapYouWereReceiving => 'You were receiving'; + + @override + String swapRefundedBody(String amount, String destination, String symbol) { + return 'The swap could not be completed, so your $amount has been returned to $destination, minus the network fee. The $symbol was never sent.'; } @override - String get swapDepositFailedTitle => 'Swap Failed'; + String swapDepositRefundReason(String reason) { + return 'Reason: $reason'; + } @override String swapDepositFailedBody(String address) { @@ -1747,13 +1823,40 @@ class AppLocalizationsEn extends AppLocalizations { 'No deposit arrived before the deadline. Do not send funds to this address anymore. Get a new quote to try again.'; @override - String get swapDepositDone => 'Done'; + String get swapStartNew => 'Start a New Swap'; + + @override + String get swapContactSupport => 'Contact Support'; + + @override + String get swapDepositAmount => 'Deposit Amount'; @override - String get swapRefundPickerTitle => 'Refund Addresses'; + String get swapDepositAmountCopied => 'Deposit amount copied to clipboard'; @override - String get swapRefundPickerEmpty => 'No recent refund addresses'; + String get swapDepositAddressCopied => 'Deposit address copied to clipboard'; + + @override + String get swapDepositWaiting => 'Waiting for your deposit…'; + + @override + String swapDepositDeadline(String time) { + return 'Send before $time or the quote expires'; + } + + @override + String get swapDepositShareQr => 'Share QR'; + + @override + String swapDepositShareContent(String network, String token, String address) { + return 'Network: $network\nToken: $token\nAddress: $address'; + } + + @override + String swapDepositNotice(String symbol, String network) { + return 'Use your $symbol or $network wallet to deposit funds. Depositing other assets may result in loss of funds.'; + } @override String get componentQrScannerTitle => 'Scan QR Code'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index 8f0deb27e..a063cd57d 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -1601,31 +1601,38 @@ class AppLocalizationsId extends AppLocalizations { String get swapTo => 'Ke'; @override - String get swapRefundAddress => 'Alamat Refund'; + String get swapExternalWallet => 'Dompet yang Anda kendalikan'; @override - String swapRefundAddressHint(String network) { - return 'Alamat $network'; + String get swapRate => 'Kurs'; + + @override + String swapRateLabel(String fromSymbol, String rate, String toSymbol) { + return '1 $fromSymbol = $rate $toSymbol'; } @override - String get swapSlippageTolerance => 'Toleransi Slippage'; + String swapSlippageLabel(String percent) { + return 'Slippage $percent%'; + } @override - String get swapRate => 'Kurs'; + String get swapSlippageTitle => 'Toleransi Slippage'; @override - String get swapGetQuote => 'Dapatkan Penawaran'; + String get swapSlippageBody => + 'Jika harga bergerak lebih dari ini sebelum swap Anda terisi, dana dikembalikan. Toleransi lebih tinggi lebih sering terisi tetapi hasilnya bisa lebih sedikit.'; @override - String swapRateLabel(String amount, String symbol, String tokenSymbol) { - return '1 $tokenSymbol = $amount $symbol'; + String swapSlippagePercent(String percent) { + return '$percent%'; } @override - String swapRateZero(String symbol, String tokenSymbol) { - return '1 $tokenSymbol = 0 $symbol'; - } + String get swapAddRecipientAddress => 'Tambahkan Alamat Penerima'; + + @override + String get swapAddRefundAddress => 'Tambahkan Alamat Refund'; @override String get swapTokenPickerTitle => 'Pilih Token'; @@ -1639,96 +1646,165 @@ class AppLocalizationsId extends AppLocalizations { } @override - String get swapReviewTitle => 'Tinjau Penawaran'; + String get swapRecipientAddressTitle => 'Alamat Penerima'; @override - String get swapReviewMinimumReceived => 'Minimum diterima'; + String get swapRefundAddressTitle => 'Alamat Refund'; @override - String get swapReviewEstimatedTime => 'Perkiraan waktu'; + String swapAddressLabel(String symbol) { + return 'Alamat $symbol'; + } @override - String swapReviewTimeSeconds(int seconds) { - return 'Sekitar $seconds detik'; + String swapAddressHint(String network) { + return 'Alamat $network'; } @override - String swapReviewTimeMinutes(int minutes) { - return 'Sekitar $minutes menit'; + String swapRecipientAddressNotice(String symbol) { + return '$symbol Anda akan tiba di alamat ini. Periksa kembali, swap tidak dapat dibatalkan.'; } @override - String swapReviewSlippageWarning(String percent) { - return 'Kurs dikunci saat deposit Anda tiba. Dengan slippage $percent% yang Anda atur, Anda menerima setidaknya minimum di atas.'; + String swapRefundAddressNotice(String symbol, String network) { + return 'Jika swap tidak dapat diselesaikan, $symbol Anda dikembalikan ke alamat ini di $network. Periksa kembali.'; } @override - String get swapReviewConfirm => 'Konfirmasi'; + String get swapSaveAddress => 'Simpan untuk swap berikutnya'; @override - String get swapDepositAmount => 'Jumlah Deposit'; + String get swapContinue => 'Lanjutkan ke Swap'; @override - String get swapDepositAmountCopied => 'Jumlah deposit disalin ke clipboard'; + String get swapSavedAddressesTitle => 'Alamat Tersimpan'; @override - String get swapDepositAddressCopied => 'Alamat deposit disalin ke clipboard'; + String get swapSavedAddressesEmpty => 'Belum ada alamat tersimpan'; @override - String get swapDepositWaiting => 'Menunggu deposit Anda…'; + String get swapReviewTitle => 'Tinjau Swap'; @override - String swapDepositDeadline(String time) { - return 'Kirim sebelum $time atau penawaran kedaluwarsa'; - } + String get swapYouPay => 'Anda bayar'; @override - String get swapDepositShareQr => 'Bagikan QR'; + String get swapYouReceive => 'Anda terima'; @override - String swapDepositShareContent(String network, String token, String address) { - return 'Jaringan: $network\nToken: $token\nAlamat: $address'; + String get swapReviewRecipient => 'Penerima'; + + @override + String get swapReviewRefundAddress => 'Alamat refund'; + + @override + String get swapReviewNetworkFee => 'Biaya jaringan'; + + @override + String get swapReviewFeeUnavailable => 'Tidak tersedia'; + + @override + String get swapReviewFeeFailed => 'Tidak dapat mengambil biaya jaringan.'; + + @override + String get swapReviewSlippage => 'Toleransi slippage'; + + @override + String swapReviewSlippageValue(String amount, String percent) { + return '$amount ($percent%)'; } @override - String swapDepositNotice(String symbol, String network) { - return 'Gunakan dompet $symbol atau $network Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.'; + String get swapReviewGuaranteedMinimum => 'Minimum terjamin'; + + @override + String swapReviewInsufficient(String symbol) { + return '$symbol tidak cukup untuk jumlah ini ditambah biaya jaringan.'; } @override - String get swapDepositProcessingTitle => 'Memproses Swap'; + String get swapReviewPriceMoved => 'Harga berubah. Periksa jumlah baru dan konfirmasi lagi.'; @override - String get swapDepositProcessingBody => 'Ini mungkin memakan waktu beberapa menit...'; + String get swapReviewConfirm => 'Konfirmasi swap'; @override - String get swapDepositCompleteTitle => 'Swap Selesai'; + String get swapInProgressTitle => 'Swap Sedang Berjalan'; @override - String swapDepositCompleteBody(String amount, String tokenSymbol) { - return 'Swap Anda untuk $amount $tokenSymbol telah selesai.'; + String get swapYourePaying => 'Anda membayar'; + + @override + String get swapYoureReceiving => 'Anda menerima'; + + @override + String swapStepSent(String symbol) { + return '$symbol terkirim'; } @override - String swapDepositDetectedBody(String network) { - return 'Deposit Anda terlihat di $network. Swap dimulai setelah dikonfirmasi.'; + String swapStepConfirming(String network) { + return 'Mengonfirmasi di $network'; } @override - String get swapDepositRefundedTitle => 'Swap Dikembalikan'; + String get swapStepSwapping => 'Menukar'; @override - String swapDepositRefundedBody(String amount, String symbol) { - return 'Swap tidak selesai. $amount $symbol dikembalikan ke alamat refund Anda.'; + String swapStepSending(String symbol, String recipient) { + return 'Mengirim $symbol ke $recipient'; } @override - String swapDepositRefundReason(String reason) { - return 'Alasan: $reason'; + String get swapProgressFooter => + 'Biasanya beberapa menit. Swap tetap selesai meskipun Anda meninggalkan layar ini. · via NEAR Intents'; + + @override + String get swapDetailsTitle => 'Detail Swap'; + + @override + String get swapDetailsRecipient => 'Alamat penerima'; + + @override + String get swapDetailsAmountSent => 'Jumlah dikirim'; + + @override + String get swapDetailsExpected => 'Perkiraan'; + + @override + String get swapDetailsTransaction => 'Transaksi'; + + @override + String get swapCompleteTitle => 'Swap Selesai'; + + @override + String get swapCompleteReceived => 'Diterima'; + + @override + String get swapCompleteTo => 'Ke:'; + + @override + String get swapFailedTitle => 'Swap Gagal'; + + @override + String get swapRefundedLabel => 'Dikembalikan'; + + @override + String get swapYouPaid => 'Anda telah membayar'; + + @override + String get swapYouWereReceiving => 'Anda akan menerima'; + + @override + String swapRefundedBody(String amount, String destination, String symbol) { + return 'Swap tidak dapat diselesaikan, jadi $amount Anda telah dikembalikan ke $destination, dikurangi biaya jaringan. $symbol tidak pernah dikirim.'; } @override - String get swapDepositFailedTitle => 'Swap Gagal'; + String swapDepositRefundReason(String reason) { + return 'Alasan: $reason'; + } @override String swapDepositFailedBody(String address) { @@ -1750,13 +1826,40 @@ class AppLocalizationsId extends AppLocalizations { 'Tidak ada deposit yang tiba sebelum tenggat waktu. Jangan kirim dana ke alamat ini lagi. Minta penawaran baru untuk mencoba lagi.'; @override - String get swapDepositDone => 'Selesai'; + String get swapStartNew => 'Mulai Swap Baru'; + + @override + String get swapContactSupport => 'Hubungi Dukungan'; + + @override + String get swapDepositAmount => 'Jumlah Deposit'; @override - String get swapRefundPickerTitle => 'Alamat Refund'; + String get swapDepositAmountCopied => 'Jumlah deposit disalin ke clipboard'; @override - String get swapRefundPickerEmpty => 'Tidak ada alamat refund terbaru'; + String get swapDepositAddressCopied => 'Alamat deposit disalin ke clipboard'; + + @override + String get swapDepositWaiting => 'Menunggu deposit Anda…'; + + @override + String swapDepositDeadline(String time) { + return 'Kirim sebelum $time atau penawaran kedaluwarsa'; + } + + @override + String get swapDepositShareQr => 'Bagikan QR'; + + @override + String swapDepositShareContent(String network, String token, String address) { + return 'Jaringan: $network\nToken: $token\nAlamat: $address'; + } + + @override + String swapDepositNotice(String symbol, String network) { + return 'Gunakan dompet $symbol atau $network Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.'; + } @override String get componentQrScannerTitle => 'Pindai Kode QR'; diff --git a/mobile-app/lib/shared/utils/account_utils.dart b/mobile-app/lib/shared/utils/account_utils.dart index 7be6d8756..a3c7a560b 100644 --- a/mobile-app/lib/shared/utils/account_utils.dart +++ b/mobile-app/lib/shared/utils/account_utils.dart @@ -18,6 +18,9 @@ int walletIndexForActiveAccount(List accounts, DisplayAccount? activeDi /// that path for testing. extension AccountSigning on Account { bool get signsWithHardware => accountType == AccountType.keystone || AppConstants.debugHardwareWallet; + + /// A transparent account whose key signs in this app. + bool get signsLocally => accountType == AccountType.local && !signsWithHardware; } List getNonHardwareWalletIndices(List accounts) { diff --git a/mobile-app/lib/v2/components/token_icon.dart b/mobile-app/lib/v2/components/token_icon.dart index a93d99d68..0753ecad9 100644 --- a/mobile-app/lib/v2/components/token_icon.dart +++ b/mobile-app/lib/v2/components/token_icon.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; class TokenIcon extends StatelessWidget { + static const _quantusAsset = 'assets/v2/token_qtc.svg'; + final SwapToken token; final double size; final double networkBadgeSize; @@ -15,6 +18,16 @@ class TokenIcon extends StatelessWidget { final iconUrl = token.iconUrl; final networkIconUrl = token.networkIconUrl; + if (token.isQuantus) { + return Container( + width: size, + height: size, + padding: EdgeInsets.all(size * 0.18), + decoration: BoxDecoration(color: colors.bgVoid, shape: BoxShape.circle), + child: SvgPicture.asset(_quantusAsset), + ); + } + return SizedBox( width: size, height: size, diff --git a/mobile-app/lib/v2/screens/home/home_screen.dart b/mobile-app/lib/v2/screens/home/home_screen.dart index 68e4a2947..faa57105b 100644 --- a/mobile-app/lib/v2/screens/home/home_screen.dart +++ b/mobile-app/lib/v2/screens/home/home_screen.dart @@ -9,6 +9,7 @@ import 'package:resonance_network_wallet/v2/components/shared_address_action_she import 'package:resonance_network_wallet/services/global_history_polling_service.dart'; import 'package:resonance_network_wallet/services/telemetry_service.dart'; import 'package:resonance_network_wallet/shared/constants/e2e_keys.dart'; +import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; import 'package:resonance_network_wallet/v2/components/amount_display_with_conversion.dart'; import 'package:resonance_network_wallet/v2/components/private_activity_notice.dart'; @@ -367,10 +368,11 @@ class _HomeScreenState extends ConsumerState { final swapCard = _actionCard( iconAsset: 'assets/v2/action_swap.svg', label: l10n.homeSwap, - // Swap is not available for encrypted accounts; keep the button visible - // but disabled so the layout doesn't change between account types. - isDisabled: isEncrypted, - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SwapScreen())), + // Swap signs the deposit locally, so only transparent accounts with a key + // here can swap; keep the button visible but disabled for the others so + // the layout doesn't change between account types. + isDisabled: !account.signsLocally, + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SwapScreen(account: account))), ); return Row( diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index 514ee6d25..bc017cb59 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -87,7 +87,7 @@ class RegularSendStrategy extends SendStrategy { /// Dummy-signed `payment_queryInfo` probe from the captured account, with /// its inputs resolved now so it can run after the requesting screen is gone. - Future Function() _feeFetcher( + Future Function() _feeFetcher( ProviderReader read, String recipient, BigInt amount, { @@ -102,6 +102,10 @@ class RegularSendStrategy extends SendStrategy { ); } + /// Chain fee for a transfer of [amount] to [recipient]. + Future fetchFee(ProviderReader read, {required String recipient, required BigInt amount}) => + _feeFetcher(read, recipient, amount, sendAll: false)(); + @override String? affordabilityError(WidgetRef ref, SendFee fee, AppLocalizations l10n) => null; @@ -250,19 +254,15 @@ class RegularSendStrategy extends SendStrategy { ); } - final authed = await LocalAuthService().authenticate(localizedReason: l10n.sendReviewAuthReason); - if (!authed) return SendFailed(l10n.sendReviewAuthRequired); - try { - final hash = await ref - .read(transactionSubmissionServiceProvider) - .balanceTransfer( - account, - call: _transferCall(ref.read, recipient, amount, sendAll: sendAll), - targetAddress: recipient, - amount: amount, - fee: regularFee.networkFee, - ); + final hash = await submitLocal( + ref, + recipient: recipient, + amount: amount, + networkFee: regularFee.networkFee, + sendAll: sendAll, + ); + if (hash == null) return SendFailed(l10n.sendReviewAuthRequired); unawaited( RecentAddressesService() .addAddress(recipient) @@ -274,4 +274,27 @@ class RegularSendStrategy extends SendStrategy { return SendFailed(l10n.sendReviewSubmitFailed); } } + + /// Authenticates the user and submits a transfer signed with the local key. + /// Returns the extrinsic hash, or null when authentication was declined. + Future submitLocal( + WidgetRef ref, { + required String recipient, + required BigInt amount, + required BigInt networkFee, + bool sendAll = false, + }) async { + if (account.signsWithHardware) throw StateError('Account ${account.accountId} signs with hardware'); + final authed = await LocalAuthService().authenticate(localizedReason: ref.read(l10nProvider).sendReviewAuthReason); + if (!authed) return null; + return ref + .read(transactionSubmissionServiceProvider) + .balanceTransfer( + account, + call: _transferCall(ref.read, recipient, amount, sendAll: sendAll), + targetAddress: recipient, + amount: amount, + fee: networkFee, + ); + } } diff --git a/mobile-app/lib/v2/screens/swap/review_swap_screen.dart b/mobile-app/lib/v2/screens/swap/review_swap_screen.dart new file mode 100644 index 000000000..b2e2aa7f6 --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/review_swap_screen.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/components/link_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_progress_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_summary.dart'; + +/// Shows a dry quote's terms. Confirming takes a live quote; when its +/// guaranteed minimum is below the one shown, the live terms replace the shown +/// ones and need a second confirmation. A swap out of QTC then sends the +/// deposit from [account]. +class ReviewSwapScreen extends ConsumerStatefulWidget { + final Account account; + final SwapQuote quote; + + const ReviewSwapScreen({super.key, required this.account, required this.quote}); + + @override + ConsumerState createState() => _ReviewSwapScreenState(); +} + +class _ReviewSwapScreenState extends ConsumerState { + static const _deadlineMargin = Duration(minutes: 2); + + late SwapQuote _quote = widget.quote; + SwapOrder? _order; + bool _confirming = false; + + bool get _swapOut => _quote.fromToken.isQuantus; + + (Account, BigInt) get _feeKey => (widget.account, _quote.amountIn); + + Future _confirm() async { + final l10n = ref.read(l10nProvider); + setState(() => _confirming = true); + try { + final order = await _liveOrder(l10n); + if (order == null || !mounted) return; + final fee = _swapOut ? ref.read(swapDepositFeeProvider(_feeKey)).requireValue : null; + final txHash = fee != null ? await _sendDeposit(order, fee, l10n) : null; + if ((fee != null && txHash == null) || !mounted) return; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => + SwapProgressScreen(account: widget.account, order: order, depositTxHash: txHash, depositFee: fee), + ), + (route) => route.isFirst, + ); + } finally { + if (mounted) setState(() => _confirming = false); + } + } + + /// A live order at least as good as the terms on screen, or null when the + /// terms on screen changed. A live order is kept until close to its deadline, + /// so a retry reuses its deposit address. + Future _liveOrder(AppLocalizations l10n) async { + final kept = _order; + if (kept != null && kept.quote.deadline.isAfter(DateTime.now().add(_deadlineMargin))) return kept; + try { + final order = await ref.read(swapServiceProvider).createSwap(_quote); + _order = order; + if (order.quote.minAmountOut >= _quote.minAmountOut) return order; + if (mounted) { + setState(() => _quote = order.quote); + context.showWarningToaster(message: l10n.swapReviewPriceMoved); + } + } catch (e) { + quantusPrint('Swap create failed: $e'); + if (mounted) context.showErrorToaster(message: l10n.swapQuoteError(describeSwapError(e))); + } + return null; + } + + /// Sends the quoted QTC into the deposit address; null when nothing was sent. + Future _sendDeposit(SwapOrder order, BigInt fee, AppLocalizations l10n) async { + try { + final hash = await RegularSendStrategy( + account: widget.account, + ).submitLocal(ref, recipient: order.depositAddress, amount: order.quote.amountIn, networkFee: fee); + if (hash == null) { + if (mounted) context.showErrorToaster(message: l10n.sendReviewAuthRequired); + return null; + } + unawaited( + ref + .read(swapServiceProvider) + .submitDeposit(order, hash) + .catchError((Object e) => quantusPrint('Swap deposit submit failed: $e')), + ); + return hash; + } catch (e) { + quantusPrint('Swap deposit transfer failed: $e'); + if (mounted) context.showErrorToaster(message: l10n.sendReviewSubmitFailed); + return null; + } + } + + @override + Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + final colors = context.colorsV3; + final text = context.themeTextV3; + final fmt = ref.watch(numberFormattingServiceProvider); + final quote = _quote; + final from = quote.fromToken; + final to = quote.toToken; + String amount(BigInt value, SwapToken token) => formatSwapAmount(l10n, fmt, value, token); + Widget row(String label, String value, {bool mono = false}) => + SwapDetailRow(label: label, value: value, monoLabel: true, monoValue: mono); + + final fee = _swapOut ? ref.watch(swapDepositFeeProvider(_feeKey)) : null; + final spendable = _swapOut ? ref.watch(effectiveMaxBalanceProviderFamily(widget.account.accountId)).value : null; + final feeValue = fee?.value; + final insufficient = feeValue != null && spendable != null && quote.amountIn + feeValue > spendable; + final blocked = _swapOut && (feeValue == null || spendable == null || insufficient); + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.swapReviewTitle), + mainContent: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + SwapAmountsCard(quote: quote, payLabel: l10n.swapYouPay, receiveLabel: l10n.swapYouReceive), + const SizedBox(height: 32), + row( + l10n.swapRate, + l10n.swapRateLabel(from.symbol, fmt.formatAmount(swapQuoteRate(quote), decimals: to.decimals), to.symbol), + ), + if (_swapOut) + row(l10n.swapReviewRecipient, AddressFormattingService.formatAddress(quote.recipient), mono: true) + else + row( + l10n.swapReviewRefundAddress, + AddressFormattingService.formatAddress(quote.refundAddress), + mono: true, + ), + if (fee != null) + row( + l10n.swapReviewNetworkFee, + fee.hasError ? l10n.swapReviewFeeUnavailable : (feeValue == null ? '…' : amount(feeValue, from)), + ), + row( + l10n.swapReviewSlippage, + l10n.swapReviewSlippageValue( + amount(quote.amountOut - quote.minAmountOut, to), + slippagePercentLabel(quote.slippageBps), + ), + ), + row(l10n.swapReviewGuaranteedMinimum, amount(quote.minAmountOut, to)), + if (fee != null && fee.hasError) ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Text(l10n.swapReviewFeeFailed, style: text.caption.copyWith(color: colors.semanticEmber)), + ), + LinkButton(label: l10n.commonRetry, onTap: () => ref.invalidate(swapDepositFeeProvider(_feeKey))), + ], + ), + ], + if (insufficient) ...[ + const SizedBox(height: 16), + Text(l10n.swapReviewInsufficient(from.symbol), style: text.caption.copyWith(color: colors.semanticEmber)), + ], + ], + ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + QuantusButton.simple( + label: l10n.swapReviewConfirm, + onTap: _confirm, + isLoading: _confirming, + isDisabled: blocked, + ), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.commonCancel, + variant: ButtonVariant.underline, + onTap: () => Navigator.pop(context), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart b/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart index b1119da16..4a1a334c4 100644 --- a/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart +++ b/mobile-app/lib/v2/screens/swap/saved_address_picker_sheet.dart @@ -4,19 +4,20 @@ import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; -Future showRefundAddressPickerSheet(BuildContext context, String network) { - return BottomSheetContainer.show(context, builder: (_) => _RefundAddressPickerContent(network: network)); +/// Addresses saved for [token]'s network; pops the one picked. +Future showSavedAddressPickerSheet(BuildContext context, SwapToken token) { + return BottomSheetContainer.show(context, builder: (_) => _SavedAddressPickerContent(token: token)); } -class _RefundAddressPickerContent extends ConsumerStatefulWidget { - final String network; - const _RefundAddressPickerContent({required this.network}); +class _SavedAddressPickerContent extends ConsumerStatefulWidget { + final SwapToken token; + const _SavedAddressPickerContent({required this.token}); @override - ConsumerState<_RefundAddressPickerContent> createState() => _RefundAddressPickerContentState(); + ConsumerState<_SavedAddressPickerContent> createState() => _SavedAddressPickerContentState(); } -class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPickerContent> { +class _SavedAddressPickerContentState extends ConsumerState<_SavedAddressPickerContent> { List _addresses = []; @override @@ -26,7 +27,7 @@ class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPicke } Future _load() async { - final addresses = await ref.read(swapServiceProvider).getRefundAddresses(widget.network); + final addresses = await ref.read(swapServiceProvider).getSavedAddresses(widget.token.network); if (mounted) setState(() => _addresses = addresses); } @@ -37,19 +38,19 @@ class _RefundAddressPickerContentState extends ConsumerState<_RefundAddressPicke final text = context.themeTextV3; return BottomSheetContainer( - title: l10n.swapRefundPickerTitle, + title: l10n.swapSavedAddressesTitle, child: Column( mainAxisSize: MainAxisSize.min, children: [ Align( alignment: Alignment.centerLeft, - child: Text(widget.network, style: text.caption.copyWith(color: colors.textMuted)), + child: Text(widget.token.networkName, style: text.caption.copyWith(color: colors.textMuted)), ), const SizedBox(height: 24), if (_addresses.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 32), - child: Text(l10n.swapRefundPickerEmpty, style: text.caption.copyWith(color: colors.textMuted)), + child: Text(l10n.swapSavedAddressesEmpty, style: text.caption.copyWith(color: colors.textMuted)), ) else ConstrainedBox( diff --git a/mobile-app/lib/v2/screens/swap/swap_address_sheet.dart b/mobile-app/lib/v2/screens/swap/swap_address_sheet.dart new file mode 100644 index 000000000..c1769f65b --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/swap_address_sheet.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/settings_checkbox.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/saved_address_picker_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; + +/// Whether the external address receives the swapped funds or gets them back. +enum SwapAddressRole { recipient, refund } + +/// Asks for the external address on [token]'s network, then [quote]s the swap +/// with it. Pops the quote, or null when dismissed. +Future showSwapAddressSheet( + BuildContext context, { + required SwapToken token, + required SwapAddressRole role, + required Future Function(String address) quote, +}) { + return BottomSheetContainer.show( + context, + builder: (_) => _SwapAddressSheet(token: token, role: role, quote: quote), + ); +} + +class _SwapAddressSheet extends ConsumerStatefulWidget { + final SwapToken token; + final SwapAddressRole role; + final Future Function(String address) quote; + + const _SwapAddressSheet({required this.token, required this.role, required this.quote}); + + @override + ConsumerState<_SwapAddressSheet> createState() => _SwapAddressSheetState(); +} + +class _SwapAddressSheetState extends ConsumerState<_SwapAddressSheet> { + static const _addressBookAsset = 'assets/v2/swap_address_book.svg'; + static const _qrAsset = 'assets/v2/swap_qr_code.svg'; + static const _iconButtonSize = 32.0; + + final _controller = TextEditingController(); + bool _save = false; + bool _quoting = false; + + String get _address => _controller.text.trim(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _continue() async { + final navigator = Navigator.of(context); + final address = _address; + setState(() => _quoting = true); + try { + final quote = await widget.quote(address); + if (_save) await ref.read(swapServiceProvider).saveAddress(widget.token.network, address); + navigator.pop(quote); + } catch (e) { + quantusPrint('Swap quote failed: $e'); + if (!mounted) return; + context.showErrorToaster(message: ref.read(l10nProvider).swapQuoteError(describeSwapError(e))); + } finally { + if (mounted) setState(() => _quoting = false); + } + } + + Future _fill(Future source) async { + final address = await source; + if (address != null && mounted) setState(() => _controller.text = address); + } + + @override + Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + final colors = context.colorsV3; + final text = context.themeTextV3; + final token = widget.token; + final recipient = widget.role == SwapAddressRole.recipient; + + return BottomSheetContainer( + title: recipient ? l10n.swapRecipientAddressTitle : l10n.swapRefundAddressTitle, + trailing: QuantusIconButton.ghost( + icon: Icons.close, + onTap: () => Navigator.pop(context), + size: IconButtonSize.small, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 16), + Text(l10n.swapAddressLabel(token.symbol), style: text.headingRow.copyWith(color: colors.textContent)), + const SizedBox(height: 12), + QuantusTextField( + controller: _controller, + hint: l10n.swapAddressHint(token.networkName), + autocorrect: false, + enableSuggestions: false, + onChanged: (_) => setState(() {}), + trailingWidth: _iconButtonSize * 2 + 12, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _iconButton(_addressBookAsset, () => _fill(showSavedAddressPickerSheet(context, token))), + const SizedBox(width: 8), + _iconButton( + _qrAsset, + () => + _fill(Navigator.push(context, MaterialPageRoute(builder: (_) => const QrScannerPage()))), + ), + const SizedBox(width: 4), + ], + ), + ), + const SizedBox(height: 24), + Text( + recipient + ? l10n.swapRecipientAddressNotice(token.symbol) + : l10n.swapRefundAddressNotice(token.symbol, token.networkName), + style: text.body.copyWith(color: colors.textMuted), + ), + const SizedBox(height: 12), + SettingsCheckbox(checked: _save, label: l10n.swapSaveAddress, onTap: () => setState(() => _save = !_save)), + const SizedBox(height: 32), + QuantusButton.simple( + label: l10n.swapContinue, + onTap: _continue, + isDisabled: _address.isEmpty, + isLoading: _quoting, + ), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.commonCancel, + variant: ButtonVariant.underline, + onTap: () => Navigator.pop(context), + ), + ], + ), + ); + } + + Widget _iconButton(String asset, VoidCallback onTap) { + final colors = context.colorsV3; + return GestureDetector( + onTap: onTap, + child: Container( + width: _iconButtonSize, + height: _iconButtonSize, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: colors.bgVoid, + borderRadius: context.radiusV3.smBorder, + border: Border.all(color: colors.borderHairline), + ), + child: SvgPicture.asset(asset, colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn)), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart index 3b294621e..a05968806 100644 --- a/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart @@ -1,309 +1,479 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/extensions/clipboard_extensions.dart'; -import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/shared/utils/open_external_url.dart'; import 'package:resonance_network_wallet/shared/utils/share_utils.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/success_check.dart'; import 'package:resonance_network_wallet/v2/components/token_icon.dart'; import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_summary.dart'; -/// Shows where to send the origin-chain funds and follows the swap from -/// 1Click's status endpoint until it settles, refunds, or fails. -class DepositScreen extends ConsumerStatefulWidget { +/// Follows a live swap through 1Click's status endpoint: the deposit address +/// while a swap into QTC waits for its deposit, then progress, and finally +/// the completed or failed swap. +class SwapProgressScreen extends ConsumerWidget { + final Account account; final SwapOrder order; - const DepositScreen({super.key, required this.order}); - @override - ConsumerState createState() => _DepositScreenState(); -} + /// Hash and fee of the QTC transfer that paid a swap out of QTC. + final String? depositTxHash; + final BigInt? depositFee; -class _DepositScreenState extends ConsumerState { - late SwapOrder _order; - Timer? _timer; - bool _polling = false; + const SwapProgressScreen({ + super.key, + required this.account, + required this.order, + this.depositTxHash, + this.depositFee, + }); @override - void initState() { - super.initState(); - _order = widget.order; - _timer = Timer.periodic(SwapService.statusPollInterval, (_) => _poll()); + Widget build(BuildContext context, WidgetRef ref) { + final current = ref.watch(swapOrderProvider(order)).value ?? order; + final awaitingDeposit = current.status == SwapStatus.pendingDeposit && !current.quote.fromToken.isQuantus; + final expired = awaitingDeposit && DateTime.now().isAfter(current.quote.deadline); + return switch (current.status) { + SwapStatus.success => _SwapComplete(order: current), + SwapStatus.refunded || + SwapStatus.failed || + SwapStatus.incompleteDeposit => _SwapFailed(order: current, account: account), + _ when expired => _SwapFailed(order: current, account: account, expired: true), + _ when awaitingDeposit => _SwapDeposit(order: current), + _ => _SwapInProgress(order: current, account: account, depositTxHash: depositTxHash, depositFee: depositFee), + }; } +} - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } +class _SwapInProgress extends ConsumerWidget { + static const _listAsset = 'assets/v2/swap_list.svg'; - Future _poll() async { - if (_polling) return; - _polling = true; - try { - final updated = await ref.read(swapServiceProvider).getSwapStatus(_order); - if (!mounted) return; - setState(() => _order = updated); - if (updated.status.isFinal) _timer?.cancel(); - } catch (e) { - quantusPrint('Swap status poll failed: $e'); - } finally { - _polling = false; - } - } + final SwapOrder order; + final Account account; + final String? depositTxHash; + final BigInt? depositFee; - bool get _expired => _order.status == SwapStatus.pendingDeposit && DateTime.now().isAfter(_order.quote.deadline); + const _SwapInProgress({required this.order, required this.account, this.depositTxHash, this.depositFee}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = ref.watch(l10nProvider); final colors = context.colorsV3; final text = context.themeTextV3; - final fmt = ref.watch(numberFormattingServiceProvider); - final quote = _order.quote; + final quote = order.quote; final from = quote.fromToken; + final to = quote.toToken; + final receiver = from.isQuantus ? AddressFormattingService.formatAddress(quote.recipient) : account.name; + final steps = [ + l10n.swapStepSent(from.symbol), + l10n.swapStepConfirming(from.networkName), + l10n.swapStepSwapping, + l10n.swapStepSending(to.symbol, receiver), + ]; + final stage = order.status == SwapStatus.processing ? 2 : 1; - final body = switch (_order.status) { - SwapStatus.pendingDeposit when _expired => _outcome( - colors, - text, - icon: Icon(Icons.timer_off_outlined, color: colors.textMuted, size: 64), - title: l10n.swapDepositExpiredTitle, - body: l10n.swapDepositExpiredBody, - ), - SwapStatus.pendingDeposit => _depositBody(l10n, colors, text, fmt), - SwapStatus.knownDepositTx => _processingBody(l10n, colors, text, l10n.swapDepositDetectedBody(from.network)), - SwapStatus.processing => _processingBody(l10n, colors, text, l10n.swapDepositProcessingBody), - SwapStatus.incompleteDeposit => _outcome( - colors, - text, - icon: Icon(Icons.warning_amber_rounded, color: colors.semanticSand, size: 64), - title: l10n.swapDepositIncompleteTitle, - body: l10n.swapDepositIncompleteBody, + return ScaffoldBase( + appBar: V2AppBar( + title: l10n.swapInProgressTitle, + trailing: GestureDetector( + onTap: () => BottomSheetContainer.show( + context, + builder: (_) => _SwapDetailsSheet(order: order, depositTxHash: depositTxHash, depositFee: depositFee), + ), + child: SvgPicture.asset( + _listAsset, + width: 20, + height: 20, + colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), + ), + ), ), - SwapStatus.success => _completedBody(l10n, colors, text, fmt), - SwapStatus.refunded => _outcome( - colors, - text, - icon: Icon(Icons.undo, color: colors.semanticSand, size: 64), - title: l10n.swapDepositRefundedTitle, - body: _refundedText(l10n, fmt), + mainContent: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 24), + SwapAmountsCard(quote: quote, payLabel: l10n.swapYourePaying, receiveLabel: l10n.swapYoureReceiving), + const SizedBox(height: 36), + for (final (i, label) in steps.indexed) + QuantusStepRow( + state: i < stage + ? StepRowState.done + : i == stage + ? StepRowState.active + : StepRowState.pending, + label: label, + ), + ], + ), ), - SwapStatus.failed => _outcome( - colors, - text, - icon: Icon(Icons.error_outline, color: colors.semanticEmber, size: 64), - title: l10n.swapDepositFailedTitle, - body: l10n.swapDepositFailedBody(_order.depositAddress), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + LinearProgressIndicator( + value: (stage + 0.5) / steps.length, + minHeight: 4, + backgroundColor: colors.bgSurface2, + color: colors.accentFlare, + borderRadius: BorderRadius.circular(2), + ), + const SizedBox(height: 32), + Text( + l10n.swapProgressFooter, + textAlign: TextAlign.center, + style: text.body.copyWith(color: colors.textMuted2), + ), + ], + ), ), - }; + ); + } +} - return ScaffoldBase( - appBar: V2AppBar( - title: l10n.swapTitle, - trailing: Icon(Icons.info_outline, color: colors.textContent, size: 24), +class _SwapDetailsSheet extends ConsumerWidget { + final SwapOrder order; + final String? depositTxHash; + final BigInt? depositFee; + + const _SwapDetailsSheet({required this.order, this.depositTxHash, this.depositFee}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final fmt = ref.watch(numberFormattingServiceProvider); + final quote = order.quote; + final fee = depositFee; + final tx = depositTxHash ?? order.originTxHashes.firstOrNull; + + return BottomSheetContainer( + title: l10n.swapDetailsTitle, + trailing: QuantusIconButton.ghost( + icon: Icons.close, + onTap: () => Navigator.pop(context), + size: IconButtonSize.small, ), - mainContent: Column( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(child: SingleChildScrollView(child: body)), - if (_order.status.isFinal || _expired) ...[const SizedBox(height: 16), _doneButton(l10n)], - const SizedBox(height: 24), + SwapDetailRow( + label: l10n.swapDetailsRecipient, + value: AddressFormattingService.formatAddress(quote.recipient), + monoValue: true, + ), + SwapDetailRow( + label: l10n.swapDetailsAmountSent, + value: formatSwapAmount(l10n, fmt, quote.amountIn, quote.fromToken), + ), + if (fee != null) + SwapDetailRow(label: l10n.swapReviewNetworkFee, value: formatSwapAmount(l10n, fmt, fee, quote.fromToken)), + SwapDetailRow( + label: l10n.swapDetailsExpected, + value: formatSwapAmount(l10n, fmt, quote.amountOut, quote.toToken), + ), + if (tx != null) ...[ + const MenuDivider(), + SwapDetailRow( + label: l10n.swapDetailsTransaction, + value: AddressFormattingService.formatAddress(tx), + monoValue: true, + ), + ], + const SizedBox(height: 32), + QuantusButton.simple( + label: l10n.commonDone, + variant: ButtonVariant.staged, + onTap: () => Navigator.pop(context), + ), ], ), ); } +} - String _refundedText(AppLocalizations l10n, NumberFormattingService fmt) { - final from = _order.quote.fromToken; - final amount = fmt.formatAmount(_order.refundedAmount ?? _order.quote.amountIn, decimals: from.decimals); - final reason = _order.refundReason; - final body = l10n.swapDepositRefundedBody(amount, from.symbol); - return reason == null ? body : '$body\n\n${l10n.swapDepositRefundReason(reason)}'; - } +class _SwapComplete extends ConsumerWidget { + final SwapOrder order; - Widget _depositBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, NumberFormattingService fmt) { - final quote = _order.quote; - final from = quote.fromToken; - final address = _order.depositAddress; - final deadline = TimeOfDay.fromDateTime(quote.deadline.toLocal()).format(context); - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, + const _SwapComplete({required this.order}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final colors = context.colorsV3; + final text = context.themeTextV3; + final fmt = ref.watch(numberFormattingServiceProvider); + final quote = order.quote; + final to = quote.toToken; + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.swapCompleteTitle), + mainContent: Center( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text(l10n.swapDepositAmount, style: text.body.copyWith(color: colors.textContent)), - const SizedBox(width: 6), - GestureDetector( - onTap: () => context.copyTextWithToaster( - fmt.formatWireAmount(quote.amountIn, decimals: from.decimals), - message: l10n.swapDepositAmountCopied, - ), - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.xsBorder), - child: Center(child: Icon(Icons.copy, color: colors.textContent, size: 12)), - ), + const SuccessCheck(size: 80), + const SizedBox(height: 40), + Text( + formatSwapAmount(l10n, fmt, order.amountOut ?? quote.amountOut, to), + style: text.titleSuccess.copyWith(color: colors.textContent), + textAlign: TextAlign.center, ), - ], - ), - const SizedBox(height: 14), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TokenIcon(token: from, size: 28, networkBadgeSize: 11), - const SizedBox(width: 8), + const SizedBox(height: 8), + Text(l10n.swapCompleteReceived, style: text.caption.copyWith(color: colors.textMuted)), + const SizedBox(height: 48), + Text(l10n.swapCompleteTo, style: text.bodyEmphasis.copyWith(color: colors.textContent)), + const SizedBox(height: 16), + Text(to.networkName, style: text.body.copyWith(color: colors.accentFlare)), + const SizedBox(height: 4), Text( - fmt.formatAmount(quote.amountIn, decimals: from.decimals), - style: text.amountHero.copyWith(color: colors.textContent), + AddressFormattingService.formatAddress(quote.recipient), + style: text.dataAddress.copyWith(color: colors.textContent), ), ], ), - const SizedBox(height: 8), - Text('\$${quote.amountInUsd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)), - const SizedBox(height: 24), - ClipRRect( - borderRadius: context.radiusV3.smBorder, - child: Container( - color: colors.textWhite, - padding: const EdgeInsets.all(8), - child: QrImageView(data: address, version: QrVersions.auto, size: 184), - ), + ), + bottomContent: ScaffoldBaseBottomContent( + child: QuantusButton.simple( + label: l10n.commonDone, + onTap: () => Navigator.popUntil(context, (route) => route.isFirst), ), - const SizedBox(height: 16), - GestureDetector( - onTap: () => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied), - child: Text( - address, - style: text.dataAddress.copyWith(color: colors.textContent), - textAlign: TextAlign.center, - ), + ), + ); + } +} + +/// A swap that ended without paying out: refunded, failed, short-paid, or a +/// deposit window that closed with nothing sent. +class _SwapFailed extends ConsumerWidget { + final SwapOrder order; + final Account account; + final bool expired; + + const _SwapFailed({required this.order, required this.account, this.expired = false}); + + String _message(AppLocalizations l10n, NumberFormattingService fmt) { + final quote = order.quote; + final from = quote.fromToken; + if (expired) return l10n.swapDepositExpiredBody; + return switch (order.status) { + SwapStatus.refunded => [ + l10n.swapRefundedBody( + formatSwapAmount(l10n, fmt, order.refundedAmount ?? quote.amountIn, from), + from.isQuantus ? account.name : AddressFormattingService.formatAddress(quote.refundAddress), + quote.toToken.symbol, ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, + if (order.refundReason != null) l10n.swapDepositRefundReason(order.refundReason!), + ].join('\n\n'), + SwapStatus.incompleteDeposit => l10n.swapDepositIncompleteBody, + _ => l10n.swapDepositFailedBody(order.depositAddress), + }; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final colors = context.colorsV3; + final text = context.themeTextV3; + final fmt = ref.watch(numberFormattingServiceProvider); + final refunded = order.status == SwapStatus.refunded; + final warning = expired || order.status == SwapStatus.incompleteDeposit; + + return ScaffoldBase( + appBar: V2AppBar( + title: expired + ? l10n.swapDepositExpiredTitle + : order.status == SwapStatus.incompleteDeposit + ? l10n.swapDepositIncompleteTitle + : l10n.swapFailedTitle, + ), + mainContent: SingleChildScrollView( + child: Column( children: [ - Loader(color: colors.semanticSage, size: 14), - const SizedBox(width: 8), - Text(l10n.swapDepositWaiting, style: text.caption.copyWith(color: colors.textMuted)), + const SizedBox(height: 24), + if (refunded) ...[ + Text( + l10n.swapRefundedLabel.toUpperCase(), + style: text.labelMonogram.copyWith(color: colors.semanticSand), + ), + const SizedBox(height: 12), + ], + if (!expired) ...[ + SwapAmountsCard( + quote: order.quote, + payLabel: l10n.swapYouPaid, + receiveLabel: l10n.swapYouWereReceiving, + amountOut: order.amountOut, + receiveDimmed: true, + ), + const SizedBox(height: 32), + ], + QuantusBanner(tone: warning ? BannerTone.sand : BannerTone.ember, message: _message(l10n, fmt)), ], ), - const SizedBox(height: 8), - Text( - l10n.swapDepositDeadline(deadline), - style: text.bodyEmphasis.copyWith(color: colors.accentFlare), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - Row( + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: QuantusButton.simple( - label: l10n.receiveCopy, - variant: ButtonVariant.staged, - onTap: () => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied), - icon: Icon(Icons.copy, color: colors.textContent, size: 20), - iconPlacement: IconPlacement.leading, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + QuantusButton.simple( + label: l10n.swapStartNew, + onTap: () => Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => SwapScreen(account: account)), + (route) => route.isFirst, ), ), - const SizedBox(width: 16), - Expanded( - child: QuantusButton.simple( - label: l10n.swapDepositShareQr, - icon: Icon(Icons.qr_code, color: colors.textContent, size: 20), - iconPlacement: IconPlacement.leading, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - variant: ButtonVariant.staged, - onTap: () => shareText(context, l10n.swapDepositShareContent(from.network, from.symbol, address)), - ), + const SizedBox(height: 4), + QuantusButton.simple( + label: l10n.swapContactSupport, + variant: ButtonVariant.underline, + onTap: () => openUrl(AppConstants.techSupportUrl), ), ], ), - const SizedBox(height: 24), - Text( - l10n.swapDepositNotice(from.symbol, from.network), - style: text.caption.copyWith(color: colors.textMuted), - textAlign: TextAlign.center, - ), - ], + ), ); } +} - Widget _processingBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, String body) { - return Column( - children: [ - const SizedBox(height: 80), - Loader(color: colors.semanticSage), - const SizedBox(height: 32), - Text(l10n.swapDepositProcessingTitle, style: text.titleScreen.copyWith(color: colors.textContent)), - const SizedBox(height: 12), - Text( - body, - style: text.bodyLarge.copyWith(color: colors.textMuted), - textAlign: TextAlign.center, - ), - ], - ); - } +/// Where to send the external token for a swap into QTC. +class _SwapDeposit extends ConsumerWidget { + final SwapOrder order; - Widget _completedBody(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, NumberFormattingService fmt) { - final to = _order.quote.toToken; - final amount = fmt.formatAmount(_order.amountOut ?? _order.quote.amountOut, decimals: to.decimals); - return Column( - children: [ - const SizedBox(height: 80), - const SuccessCheck(), - const SizedBox(height: 32), - Text(l10n.swapDepositCompleteTitle, style: text.titleSuccess.copyWith(color: colors.textContent)), - const SizedBox(height: 12), - Text( - l10n.swapDepositCompleteBody(amount, to.symbol), - style: text.bodyLarge.copyWith(color: colors.textMuted), - textAlign: TextAlign.center, - ), - ], - ); - } + const _SwapDeposit({required this.order}); - Widget _outcome( - AppColorsV3 colors, - AppTextThemeV3 text, { - required Widget icon, - required String title, - required String body, - }) { - return Column( - children: [ - const SizedBox(height: 80), - icon, - const SizedBox(height: 32), - Text( - title, - style: text.titleScreen.copyWith(color: colors.textContent), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - body, - style: text.bodyLarge.copyWith(color: colors.textMuted), - textAlign: TextAlign.center, - ), - ], - ); - } + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final colors = context.colorsV3; + final text = context.themeTextV3; + final fmt = ref.watch(numberFormattingServiceProvider); + final quote = order.quote; + final from = quote.fromToken; + final address = order.depositAddress; + final deadline = TimeOfDay.fromDateTime(quote.deadline.toLocal()).format(context); + Future copyAddress() => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied); - Widget _doneButton(AppLocalizations l10n) { - return QuantusButton.simple( - label: l10n.swapDepositDone, - onTap: () => Navigator.popUntil(context, (r) => r.isFirst), - variant: ButtonVariant.staged, + return ScaffoldBase( + appBar: V2AppBar(title: l10n.swapTitle), + mainContent: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(l10n.swapDepositAmount, style: text.body.copyWith(color: colors.textContent)), + const SizedBox(width: 6), + GestureDetector( + onTap: () => context.copyTextWithToaster( + fmt.formatWireAmount(quote.amountIn, decimals: from.decimals), + message: l10n.swapDepositAmountCopied, + ), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.xsBorder), + child: Center(child: Icon(Icons.copy, color: colors.textContent, size: 12)), + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TokenIcon(token: from, size: 28, networkBadgeSize: 11), + const SizedBox(width: 8), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + fmt.formatExactAmount(quote.amountIn, decimals: from.decimals), + style: text.amountHero.copyWith(color: colors.textContent), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text('\$${quote.amountInUsd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)), + const SizedBox(height: 24), + ClipRRect( + borderRadius: context.radiusV3.smBorder, + child: Container( + color: colors.textWhite, + padding: const EdgeInsets.all(8), + child: QrImageView(data: address, version: QrVersions.auto, size: 184), + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: copyAddress, + child: Text( + address, + style: text.dataAddress.copyWith(color: colors.textContent), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Loader(color: colors.semanticSage, size: 14), + const SizedBox(width: 8), + Text(l10n.swapDepositWaiting, style: text.caption.copyWith(color: colors.textMuted)), + ], + ), + const SizedBox(height: 8), + Text( + l10n.swapDepositDeadline(deadline), + style: text.bodyEmphasis.copyWith(color: colors.accentFlare), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: QuantusButton.simple( + label: l10n.receiveCopy, + variant: ButtonVariant.staged, + onTap: copyAddress, + icon: Icon(Icons.copy, color: colors.textContent, size: 20), + iconPlacement: IconPlacement.leading, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + ), + const SizedBox(width: 16), + Expanded( + child: QuantusButton.simple( + label: l10n.swapDepositShareQr, + icon: Icon(Icons.qr_code, color: colors.textContent, size: 20), + iconPlacement: IconPlacement.leading, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + variant: ButtonVariant.staged, + onTap: () => + shareText(context, l10n.swapDepositShareContent(from.networkName, from.symbol, address)), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + l10n.swapDepositNotice(from.symbol, from.networkName), + style: text.caption.copyWith(color: colors.textMuted), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ], + ), + ), ); } } diff --git a/mobile-app/lib/v2/screens/swap/swap_providers.dart b/mobile-app/lib/v2/screens/swap/swap_providers.dart index f2ae6a368..ae0943ff5 100644 --- a/mobile-app/lib/v2/screens/swap/swap_providers.dart +++ b/mobile-app/lib/v2/screens/swap/swap_providers.dart @@ -1,14 +1,75 @@ +import 'dart:math'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/legacy.dart'; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; import 'package:resonance_network_wallet/providers/currency_display_provider.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/screens/send/regular_send_strategy.dart'; final swapServiceProvider = Provider((_) => SwapService()); -/// QTC as a swap destination, priced with the same rate the rest of the app uses. +/// Slippage tolerances offered for a quote, in basis points. +const swapSlippageOptionsBps = [50, 100, 200, 300]; + +/// Slippage tolerance the next quote asks for, in basis points. +final swapSlippageBpsProvider = StateProvider((_) => SwapService.defaultSlippageBps); + +/// QTC as a swap token, priced with the same rate the rest of the app uses. final quantusSwapTokenProvider = Provider( (ref) => SwapService.quantusToken(usdPrice: ref.watch(exchangeRateServiceProvider).tokenToUsdRate.toDouble()), ); +/// Chain fee for sending [amount] QTC from the account into a deposit address. +/// The recipient barely moves the fee, so the account's own address stands in +/// until the live quote names the deposit address. +final swapDepositFeeProvider = FutureProvider.autoDispose.family((ref, args) async { + final (account, amount) = args; + try { + return (await RegularSendStrategy( + account: account, + ).fetchFee(ref.read, recipient: account.accountId, amount: amount)).networkFee; + } catch (e) { + quantusPrint('Swap deposit fee failed: $e'); + rethrow; + } +}, retry: (_, _) => null); + +/// [order] followed through 1Click's status endpoint until it settles. +final swapOrderProvider = StreamProvider.autoDispose.family((ref, order) async* { + final service = ref.watch(swapServiceProvider); + var current = order; + yield current; + while (!current.status.isFinal) { + await Future.delayed(SwapService.statusPollInterval); + if (!ref.mounted) return; + try { + current = await service.getSwapStatus(current); + yield current; + } catch (e) { + quantusPrint('Swap status poll failed: $e'); + } + } +}); + +/// USD value of [amount] base units of [token] at its listed price. +double swapUsdValue(BigInt amount, SwapToken token) => amount.toDouble() / pow(10, token.decimals) * token.usdPrice; + +/// Base units of [to] that [amountIn] of [from] is worth at listed prices. +BigInt swapEstimateOut(BigInt amountIn, SwapToken from, SwapToken to) { + if (to.usdPrice <= 0) return BigInt.zero; + return BigInt.from(swapUsdValue(amountIn, from) / to.usdPrice * pow(10, to.decimals)); +} + +/// Base units of [quote]'s output token that one whole input token buys. +BigInt swapQuoteRate(SwapQuote quote) => + quote.amountOut * BigInt.from(10).pow(quote.fromToken.decimals) ~/ quote.amountIn; + +/// [value] base units of [token] with its symbol, e.g. "24.86 USDC". +String formatSwapAmount(AppLocalizations l10n, NumberFormattingService fmt, BigInt value, SwapToken token) => + l10n.commonAmountBalance(fmt.formatAmount(value, decimals: token.decimals), token.symbol); + String describeSwapError(Object error) => error is SwapApiException ? error.message : error.toString(); String slippagePercentLabel(int bps) { diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 19654658a..61218ffc8 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -1,52 +1,52 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:quantus_sdk/quantus_sdk.dart' hide ScaffoldBase; import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/utils/print.dart'; -import 'package:resonance_network_wallet/v2/components/qr_scanner_page.dart'; -import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/near_intents_attribution.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/token_icon.dart'; -import 'package:resonance_network_wallet/v2/screens/swap/refund_address_picker_sheet.dart'; -import 'package:resonance_network_wallet/v2/screens/swap/review_quote_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/review_swap_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_address_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_slippage_sheet.dart'; import 'package:resonance_network_wallet/v2/screens/swap/token_picker_sheet.dart'; +/// Swaps between QTC in [account] and a token on another chain, either way. class SwapScreen extends ConsumerStatefulWidget { - const SwapScreen({super.key}); + final Account account; + + const SwapScreen({super.key, required this.account}); @override ConsumerState createState() => _SwapScreenState(); } class _SwapScreenState extends ConsumerState { - static const _qrIconAsset = 'assets/v2/swap_qr_code.svg'; - static const _historyIconAsset = 'assets/v2/swap_clock_counter_clockwise.svg'; - static const _swapDirectionIconAsset = 'assets/v2/swap_arrows_down_up.svg'; + static const _directionIconAsset = 'assets/v2/swap_arrows_down_up.svg'; + static const _slippageIconAsset = 'assets/v2/swap_pencil.svg'; + static const _amountBoxWidth = 125.0; + static const _rowHeight = 44.0; - final _fromController = TextEditingController(); - final _addressController = TextEditingController(); - SwapToken? _fromToken; + final _amountController = TextEditingController(); + SwapToken? _foreign; bool _loadingTokens = true; - bool _quoting = false; + bool _swapOut = true; @override void initState() { super.initState(); - _fromController.addListener(() => setState(() {})); + _amountController.addListener(() => setState(() {})); _loadTokens(); } @override void dispose() { - _fromController.dispose(); - _addressController.dispose(); + _amountController.dispose(); super.dispose(); } @@ -55,7 +55,7 @@ class _SwapScreenState extends ConsumerState { try { final tokens = await ref.read(swapServiceProvider).getFromTokens(forceRefresh: forceRefresh); if (!mounted) return; - setState(() => _fromToken ??= tokens.first); + setState(() => _foreign ??= tokens.first); } catch (e) { quantusPrint('Swap tokens failed to load: $e'); } finally { @@ -64,77 +64,54 @@ class _SwapScreenState extends ConsumerState { } BigInt _amountIn(SwapToken from) => - ref.read(numberFormattingServiceProvider).parseAmount(_fromController.text, decimals: from.decimals) ?? + ref.read(numberFormattingServiceProvider).parseAmount(_amountController.text, decimals: from.decimals) ?? BigInt.zero; - static double _usd(BigInt amount, SwapToken token) => amount.toDouble() / pow(10, token.decimals) * token.usdPrice; - - static BigInt _estimateOut(BigInt amountIn, SwapToken from, SwapToken to) { - if (to.usdPrice <= 0) return BigInt.zero; - return BigInt.from(_usd(amountIn, from) / to.usdPrice * pow(10, to.decimals)); - } - - static String _amountHint(SwapToken token) => token.decimals == 0 ? '0' : '0.${'0' * token.decimals.clamp(1, 8)}'; - - String _rateLabel(AppLocalizations l10n, SwapToken from, SwapToken to) { - if (from.usdPrice <= 0 || to.usdPrice <= 0) return l10n.swapRateZero(from.symbol, to.symbol); - final val = to.usdPrice / from.usdPrice; - final decimals = val >= 100 - ? 2 - : val >= 1 - ? 4 - : val >= 0.01 - ? 6 - : val >= 0.0001 - ? 8 - : 10; - var formatted = val.toStringAsFixed(decimals).replaceAll(RegExp(r'0+$'), ''); - if (formatted.endsWith('.')) formatted = formatted.substring(0, formatted.length - 1); - return l10n.swapRateLabel(formatted, from.symbol, to.symbol); - } - - Future _getQuote(SwapToken from, SwapToken to) async { - final refund = _addressController.text.trim(); - final recipient = ref.read(activeAccountProvider).value!.account.accountId; - final service = ref.read(swapServiceProvider); - setState(() => _quoting = true); - try { - final quote = await service.getQuote( - from: from, - to: to, - amount: _amountIn(from), - refundAddress: refund, - recipient: recipient, - ); - await service.addRefundAddress(from.network, refund); - if (!mounted) return; - showReviewQuoteSheet(context, quote); - } catch (e) { - quantusPrint('Swap quote failed: $e'); - if (!mounted) return; - context.showErrorToaster(message: ref.read(l10nProvider).swapQuoteError(describeSwapError(e))); - } finally { - if (mounted) setState(() => _quoting = false); - } + void _setDirection({required bool swapOut}) { + if (swapOut == _swapOut) return; + setState(() => _swapOut = swapOut); + _amountController.clear(); } - void _pickToken(SwapToken from) async { + /// Picks the external token. Picking from the QTC side moves QTC to the other side. + Future _pickToken(SwapToken tapped) async { final service = ref.read(swapServiceProvider); + final foreign = _foreign!; final token = await showTokenPickerSheet( context, - current: from, + current: foreign, loadTokens: ({bool forceRefresh = false}) => service.getFromTokens(limit: 10, forceRefresh: forceRefresh), ); - if (!mounted) return; - if (token != null && token != from) setState(() => _fromToken = token); + if (token == null || !mounted) return; + setState(() => _foreign = token); + if (tapped.isQuantus) _setDirection(swapOut: !_swapOut); } - void _scanQr() async { - final address = await Navigator.push(context, MaterialPageRoute(builder: (_) => const QrScannerPage())); - if (address != null && mounted) { - _addressController.text = address; - setState(() {}); - } + Future _addAddress(SwapToken from, SwapToken to, SwapToken foreign) async { + final service = ref.read(swapServiceProvider); + final account = widget.account; + final amount = _amountIn(from); + final swapOut = _swapOut; + final quote = await showSwapAddressSheet( + context, + token: foreign, + role: swapOut ? SwapAddressRole.recipient : SwapAddressRole.refund, + quote: (address) => service.getQuote( + from: from, + to: to, + amount: amount, + refundAddress: swapOut ? account.accountId : address, + recipient: swapOut ? address : account.accountId, + slippageBps: ref.read(swapSlippageBpsProvider), + ), + ); + if (quote == null || !mounted) return; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ReviewSwapScreen(account: account, quote: quote), + ), + ); } @override @@ -142,8 +119,8 @@ class _SwapScreenState extends ConsumerState { final l10n = ref.watch(l10nProvider); final colors = context.colorsV3; final text = context.themeTextV3; - final from = _fromToken; - final to = ref.watch(quantusSwapTokenProvider); + final foreign = _foreign; + final quantus = ref.watch(quantusSwapTokenProvider); return ScaffoldBase( appBar: V2AppBar( @@ -151,7 +128,12 @@ class _SwapScreenState extends ConsumerState { slotWidth: NearIntentsAttribution.width, trailing: const NearIntentsAttribution(), ), - mainContent: from == null ? _tokensState(l10n, colors, text) : _form(l10n, colors, text, from, to), + mainContent: foreign == null + ? _tokensState(l10n, colors, text) + : _form(l10n, colors, text, _swapOut ? quantus : foreign, _swapOut ? foreign : quantus), + bottomContent: foreign == null + ? null + : _cta(l10n, _swapOut ? quantus : foreign, _swapOut ? foreign : quantus, foreign), ); } @@ -172,293 +154,251 @@ class _SwapScreenState extends ConsumerState { ); } - Widget _form(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, SwapToken to) { + Widget _cta(AppLocalizations l10n, SwapToken from, SwapToken to, SwapToken foreign) { final amountIn = _amountIn(from); - final estimateOut = _estimateOut(amountIn, from, to); - final enabled = amountIn > BigInt.zero && _addressController.text.trim().isNotEmpty && !_quoting; - return Column( - children: [ - const SizedBox(height: 24), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _fromSection(l10n, colors, text, from, amountIn), - const SizedBox(height: 32), - _refundAddressSection(l10n, colors, text, from), - const SizedBox(height: 32), - _swapDivider(colors), - const SizedBox(height: 32), - _toSection(l10n, colors, text, to, estimateOut), - const SizedBox(height: 32), - _infoSection(l10n, colors, text, from, to), - ], - ), - ), - ), - const SizedBox(height: 16), - QuantusButton.simple( - label: l10n.swapGetQuote, - onTap: () => _getQuote(from, to), - isDisabled: !enabled, - isLoading: _quoting, - variant: ButtonVariant.staged, - ), - const SizedBox(height: 24), - ], + final spendable = _swapOut ? ref.watch(effectiveMaxBalanceProviderFamily(widget.account.accountId)).value : null; + final insufficient = spendable != null && amountIn > spendable; + return ScaffoldBaseBottomContent( + child: QuantusButton.simple( + label: insufficient + ? l10n.sendLogicInsufficientBalance + : _swapOut + ? l10n.swapAddRecipientAddress + : l10n.swapAddRefundAddress, + onTap: () => _addAddress(from, to, foreign), + isDisabled: amountIn == BigInt.zero || insufficient, + ), ); } - Widget _fromSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, BigInt amountIn) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.swapFrom, style: text.body.copyWith(color: colors.textContent)), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder), - alignment: Alignment.centerLeft, - child: TextField( - controller: _fromController, - style: text.amountHero.copyWith(color: colors.textContent), - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: InputDecoration( - hintText: _amountHint(from), - hintStyle: text.amountHero.copyWith(color: colors.textMuted2), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - filled: true, - fillColor: Colors.transparent, + Widget _form(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, SwapToken to) { + final fmt = ref.watch(numberFormattingServiceProvider); + final amountIn = _amountIn(from); + final estimateOut = swapEstimateOut(amountIn, from, to); + final oneFrom = BigInt.from(10).pow(from.decimals); + final priced = from.usdPrice > 0 && to.usdPrice > 0; + final wallet = widget.account.name; + final external = l10n.swapExternalWallet; + + return SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 24), + Container( + decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.pillBorder), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.center, + children: [ + Column( + children: [ + _section( + colors, + text, + label: l10n.swapFrom, + owner: _swapOut ? wallet : external, + token: from, + usd: swapUsdValue(amountIn, from), + amount: _amountField(colors, text, from), + ), + Container(height: 4, color: colors.bgVoid), + _section( + colors, + text, + label: l10n.swapTo, + owner: _swapOut ? external : wallet, + token: to, + usd: swapUsdValue(estimateOut, to), + amount: _amountBox( + colors, + Text( + fmt.formatAmount(estimateOut, decimals: to.decimals), + style: text.amountInline.copyWith( + color: estimateOut > BigInt.zero ? colors.textContent : colors.textMuted2, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + GestureDetector( + onTap: () => _setDirection(swapOut: !_swapOut), + child: Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: colors.bgVoid, shape: BoxShape.circle), + child: SvgPicture.asset( + _directionIconAsset, + colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), + ), ), ), - ), + ], ), - const SizedBox(width: 16), - SizedBox( - width: 119, - height: 56, - child: QuantusButton( - centered: false, - variant: ButtonVariant.glass, - onTap: () => _pickToken(from), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - borderRadius: context.radiusV3.mdBorder, + ), + const SizedBox(height: 12), + Row( + children: [ + if (priced) + Expanded( + child: Text( + l10n.swapRateLabel( + from.symbol, + fmt.formatAmount(swapEstimateOut(oneFrom, from, to), decimals: to.decimals), + to.symbol, + ), + style: text.bodyEmphasis.copyWith(color: colors.textMuted2), + ), + ) + else + const Spacer(), + GestureDetector( + onTap: () => showSwapSlippageSheet(context), + behavior: HitTestBehavior.opaque, child: Row( + mainAxisSize: MainAxisSize.min, children: [ - TokenIcon(token: from, size: 25, networkBadgeSize: 10), + Text( + l10n.swapSlippageLabel(slippagePercentLabel(ref.watch(swapSlippageBpsProvider))), + style: text.bodyEmphasis.copyWith(color: colors.textMuted2), + ), const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - from.symbol, - style: text.caption.copyWith(color: colors.textContent), - overflow: TextOverflow.ellipsis, - ), - Text( - from.network, - style: text.caption.copyWith(color: colors.textMuted), - overflow: TextOverflow.ellipsis, - ), - ], + Container( + width: 20, + height: 20, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: colors.bgVoid, + shape: BoxShape.circle, + border: Border.all(color: colors.borderHairline), + ), + child: SvgPicture.asset( + _slippageIconAsset, + colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), ), ), - const SizedBox(width: 4), - QuantusIcon(QuantusIcons.caretDown, color: colors.textMuted, size: 16), ], ), ), - ), - ], - ), - const SizedBox(height: 4), - Row( - children: [ - Text('\$${_usd(amountIn, from).toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), - const SizedBox(width: 4), - QuantusIcon(QuantusIcons.swapVertical, color: colors.textMuted, size: 12), - ], - ), - ], + ], + ), + ], + ), ); } - Widget _refundAddressSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(l10n.swapRefundAddress, style: text.body.copyWith(color: colors.textContent)), - const SizedBox(width: 4), - Icon(Icons.info_outline, color: colors.textMuted, size: 14), - ], - ), - const SizedBox(height: 12), - Container( - decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder), - padding: const EdgeInsets.only(left: 12, right: 8, top: 8, bottom: 8), - child: Row( + Widget _section( + AppColorsV3 colors, + AppTextThemeV3 text, { + required String label, + required String owner, + required SwapToken token, + required double usd, + required Widget amount, + }) { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label.toUpperCase(), style: text.labelData.copyWith(color: colors.textMuted)), + Text(owner, style: text.caption.copyWith(color: colors.textMuted)), + ], + ), + const SizedBox(height: 16), + Row( children: [ Expanded( - child: TextField( - controller: _addressController, - style: text.dataAddressLarge.copyWith(color: colors.textContent), - decoration: InputDecoration( - hintText: l10n.swapRefundAddressHint(from.network), - hintStyle: text.dataAddressLarge.copyWith(color: colors.textMuted), - border: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - filled: true, - fillColor: Colors.transparent, - ), - onChanged: (_) => setState(() {}), - ), - ), - const SizedBox(width: 8), - _smallIconButton(colors: colors, iconAsset: _qrIconAsset, onTap: _scanQr), - const SizedBox(width: 8), - _smallIconButton( - colors: colors, - iconAsset: _historyIconAsset, - onTap: () async { - final address = await showRefundAddressPickerSheet(context, from.network); - if (address != null) { - _addressController.text = address; - setState(() {}); - } - }, + child: Align(alignment: Alignment.centerLeft, child: _tokenPill(colors, text, token)), ), + const SizedBox(width: 12), + amount, ], ), - ), - ], - ); - } - - Widget _swapDivider(AppColorsV3 colors) { - return Row( - children: [ - const Expanded(child: MenuDivider()), - SizedBox( - width: 40, - height: 40, - child: _smallIconButton(colors: colors, iconAsset: _swapDirectionIconAsset, onTap: () {}), - ), - const Expanded(child: MenuDivider()), - ], + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: Text('\$${usd.toStringAsFixed(2)}', style: text.body.copyWith(color: colors.textMuted)), + ), + ], + ), ); } - Widget _smallIconButton({required AppColorsV3 colors, required String iconAsset, VoidCallback? onTap}) { + Widget _tokenPill(AppColorsV3 colors, AppTextThemeV3 text, SwapToken token) { return SizedBox( - width: 40, - height: 40, + height: _rowHeight, child: QuantusButton( - onTap: onTap, + width: null, + centered: false, variant: ButtonVariant.glass, - padding: EdgeInsets.zero, - borderRadius: context.radiusV3.smBorder, - child: Center( - child: SvgPicture.asset( - iconAsset, - width: 20, - height: 20, - colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn), - ), - ), - ), - ); - } - - Widget _toSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken to, BigInt estimateOut) { - final fmt = ref.watch(numberFormattingServiceProvider); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.swapTo, style: text.body.copyWith(color: colors.textContent)), - const SizedBox(height: 12), - Row( + onTap: () => _pickToken(token), + padding: const EdgeInsets.only(left: 6, right: 12), + borderRadius: context.radiusV3.pillBorder, + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.mdBorder), - alignment: Alignment.centerLeft, - child: Text( - fmt.formatAmount(estimateOut, decimals: to.decimals), - style: text.amountHero.copyWith( - color: estimateOut > BigInt.zero ? colors.textContent : colors.textMuted2, + TokenIcon(token: token, size: 32, networkBadgeSize: 12), + const SizedBox(width: 8), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + token.symbol, + style: text.bodyEmphasis.copyWith(color: colors.textContent), + overflow: TextOverflow.ellipsis, ), - ), - ), - ), - const SizedBox(width: 16), - SizedBox( - width: 119, - height: 56, - child: QuantusButton( - centered: false, - variant: ButtonVariant.glass, - onTap: () {}, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - borderRadius: context.radiusV3.mdBorder, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - TokenIcon(token: to, size: 25, networkBadgeSize: 10), - const SizedBox(width: 8), - Text(to.symbol, style: text.body.copyWith(color: colors.textContent)), - ], - ), + Text( + token.networkName, + style: text.caption.copyWith(color: colors.textMuted), + overflow: TextOverflow.ellipsis, + ), + ], ), ), + const SizedBox(width: 8), + QuantusIcon(QuantusIcons.caretDown, color: colors.textMuted, size: 14), ], ), - const SizedBox(height: 4), - Text('\$${_usd(estimateOut, to).toStringAsFixed(2)}', style: text.caption.copyWith(color: colors.textMuted)), - ], + ), ); } - Widget _infoSection(AppLocalizations l10n, AppColorsV3 colors, AppTextThemeV3 text, SwapToken from, SwapToken to) { - return Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(l10n.swapSlippageTolerance, style: text.caption.copyWith(color: colors.textMuted)), - Row( - children: [ - Text( - '${slippagePercentLabel(SwapService.defaultSlippageBps)}%', - style: text.caption.copyWith(color: colors.textMuted), - ), - const SizedBox(width: 4), - Icon(Icons.settings, color: colors.textMuted, size: 12), - ], - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(l10n.swapRate, style: text.caption.copyWith(color: colors.textMuted)), - Text(_rateLabel(l10n, from, to), style: text.caption.copyWith(color: colors.textMuted)), - ], + Widget _amountBox(AppColorsV3 colors, Widget child) { + return Container( + width: _amountBoxWidth, + height: _rowHeight, + padding: const EdgeInsets.only(left: 12, right: 8), + alignment: Alignment.centerRight, + decoration: BoxDecoration(color: colors.textWhite.useOpacity(0.05), borderRadius: context.radiusV3.smBorder), + child: child, + ); + } + + Widget _amountField(AppColorsV3 colors, AppTextThemeV3 text, SwapToken from) { + return _amountBox( + colors, + TextField( + controller: _amountController, + textAlign: TextAlign.right, + style: text.amountInline.copyWith(color: colors.textContent), + cursorColor: colors.accentFlare, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + DecimalInputFilter(localeConfig: ref.watch(localeNumberConfigProvider), maxDecimalPlaces: from.decimals), + ], + decoration: InputDecoration.collapsed( + hintText: '0', + hintStyle: text.amountInline.copyWith(color: colors.textMuted2), ), - ], + ), ); } } diff --git a/mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart b/mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart new file mode 100644 index 000000000..534e79f50 --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/swap_slippage_sheet.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; + +/// Picks the slippage tolerance for the next quote; closes on a pick. +Future showSwapSlippageSheet(BuildContext context) => + BottomSheetContainer.show(context, builder: (_) => const _SwapSlippageSheet()); + +class _SwapSlippageSheet extends ConsumerWidget { + const _SwapSlippageSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(l10nProvider); + final colors = context.colorsV3; + final text = context.themeTextV3; + + return BottomSheetContainer( + title: l10n.swapSlippageTitle, + trailing: QuantusIconButton.ghost( + icon: Icons.close, + onTap: () => Navigator.pop(context), + size: IconButtonSize.small, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.swapSlippageBody, style: text.body.copyWith(color: colors.textMuted)), + const SizedBox(height: 24), + SegmentedControls( + items: [ + for (final bps in swapSlippageOptionsBps) + SegmentedControlItem(value: bps, label: l10n.swapSlippagePercent(slippagePercentLabel(bps))), + ], + selectedValue: ref.watch(swapSlippageBpsProvider), + onChanged: (bps) { + ref.read(swapSlippageBpsProvider.notifier).state = bps; + Navigator.pop(context); + }, + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/swap/swap_summary.dart b/mobile-app/lib/v2/screens/swap/swap_summary.dart new file mode 100644 index 000000000..b36b7ee1a --- /dev/null +++ b/mobile-app/lib/v2/screens/swap/swap_summary.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/components/token_icon.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; + +/// Pay/receive summary of a swap, shared by the review, progress and failed screens. +class SwapAmountsCard extends ConsumerWidget { + static const _arrowAsset = 'assets/v2/swap_arrow_down.svg'; + + final SwapQuote quote; + final String payLabel; + final String receiveLabel; + final BigInt? amountOut; + final bool receiveDimmed; + + const SwapAmountsCard({ + super.key, + required this.quote, + required this.payLabel, + required this.receiveLabel, + this.amountOut, + this.receiveDimmed = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = context.colorsV3; + return Container( + decoration: BoxDecoration(color: colors.bgSurface, borderRadius: context.radiusV3.pillBorder), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.center, + children: [ + Column( + children: [ + _row(context, ref, quote.fromToken, quote.amountIn, payLabel, dimmed: false), + Container(height: 4, color: colors.bgVoid), + _row(context, ref, quote.toToken, amountOut ?? quote.amountOut, receiveLabel, dimmed: receiveDimmed), + ], + ), + Container( + width: 32, + height: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration(color: colors.bgVoid, shape: BoxShape.circle), + child: SvgPicture.asset(_arrowAsset, colorFilter: ColorFilter.mode(colors.textContent, BlendMode.srcIn)), + ), + ], + ), + ); + } + + Widget _row( + BuildContext context, + WidgetRef ref, + SwapToken token, + BigInt amount, + String label, { + required bool dimmed, + }) { + final colors = context.colorsV3; + final text = context.themeTextV3; + return Padding( + padding: const EdgeInsets.all(24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + TokenIcon(token: token, size: 32, networkBadgeSize: 12), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label.toUpperCase(), style: text.labelMonogram.copyWith(color: colors.textMuted)), + const SizedBox(height: 8), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + formatSwapAmount( + ref.watch(l10nProvider), + ref.watch(numberFormattingServiceProvider), + amount, + token, + ), + style: text.amountHero.copyWith(color: dimmed ? colors.textMuted2 : colors.textContent), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Label/value line of a swap summary; [monoLabel] gives the uppercase mono +/// label of the review screen. +class SwapDetailRow extends StatelessWidget { + final String label; + final String value; + final bool monoLabel; + final bool monoValue; + + const SwapDetailRow({ + super.key, + required this.label, + required this.value, + this.monoLabel = false, + this.monoValue = false, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colorsV3; + final text = context.themeTextV3; + return DetailSummaryRow( + label: monoLabel ? label.toUpperCase() : label, + value: value, + labelFlex: 1, + valueFlex: 1, + labelStyle: (monoLabel ? text.dataAddress : text.caption).copyWith(color: colors.textMuted), + valueStyle: (monoValue ? text.dataAddress : text.labelChip).copyWith(color: colors.textContent), + ); + } +} diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 9abdd4329..70440012f 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -95,8 +95,12 @@ flutter: - assets/v2/uppercase_q.png - assets/v2/hand_arrow_down.svg - assets/v2/swap_arrows_down_up.svg - - assets/v2/swap_clock_counter_clockwise.svg - assets/v2/swap_qr_code.svg + - assets/v2/swap_arrow_down.svg + - assets/v2/swap_list.svg + - assets/v2/swap_address_book.svg + - assets/v2/swap_pencil.svg + - assets/v2/token_qtc.svg - assets/v2/auth_wrapper_bracket.png - assets/v2/splash_background.jpg - assets/v2/ascii_background.png diff --git a/mobile-app/test/screens/swap_flow_test.dart b/mobile-app/test/screens/swap_flow_test.dart new file mode 100644 index 000000000..51ef755b7 --- /dev/null +++ b/mobile-app/test/screens/swap_flow_test.dart @@ -0,0 +1,337 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/connectivity_provider.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/services/transaction_submission_service.dart'; +import 'package:resonance_network_wallet/v2/screens/send/send_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/review_swap_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_progress_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_providers.dart'; +import 'package:resonance_network_wallet/v2/screens/swap/swap_screen.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../extensions.dart'; +import '../fakes.dart'; + +const _usdc = SwapToken(assetId: 'nep141:usdc.omft.near', symbol: 'USDC', network: 'ETH', decimals: 6, usdPrice: 1); +const _external = '0xa5f3c2b1d4e8a7c9f2b5e6d7c8b9a0f1e2d3c7b9'; +final _deposit = 'qzdeposit${'y' * 40}'; +final _qtc = SwapService.quantusToken(usdPrice: 0.1); +final _unit = BigInt.from(10).pow(AppConstants.decimals); + +class _FakeSubmission extends Fake implements TransactionSubmissionService { + final transfers = <(String, BigInt, BigInt)>[]; + + @override + Future balanceTransfer( + Account account, { + required RuntimeCall call, + required String targetAddress, + required BigInt amount, + required BigInt fee, + }) async { + transfers.add((targetAddress, amount, fee)); + return '0xtxhash'; + } +} + +/// 1Click stand-in: a dry quote pays [dryOut], a live one [liveOut] with a +/// deposit address, both with 1% slippage. +class _OneClick { + final BigInt dryOut; + final BigInt liveOut; + final requests = []; + + _OneClick({required this.dryOut, BigInt? liveOut}) : liveOut = liveOut ?? dryOut; + + Iterable get liveQuotes => + requests.where((r) => r.url.path == '/v0/quote' && (jsonDecode(r.body) as Map)['dry'] == false); + + SwapService service() => SwapService(endpoint: 'https://oneclick.test', client: MockClient(_handle)); + + Future _handle(http.Request request) async { + requests.add(request); + return switch (request.url.path) { + '/v0/tokens' => http.Response( + jsonEncode([ + {'assetId': _usdc.assetId, 'decimals': 6, 'blockchain': 'eth', 'symbol': 'USDC', 'price': 1}, + ]), + 200, + ), + '/v0/quote' => http.Response(jsonEncode(_quoteJson(jsonDecode(request.body) as Map)), 200), + '/v0/deposit/submit' => http.Response('{}', 200), + '/v0/status' => http.Response(jsonEncode({'status': 'PENDING_DEPOSIT'}), 200), + _ => http.Response('down', 503), + }; + } + + Map _quoteJson(Map request) { + final dry = request['dry'] as bool; + final out = dry ? dryOut : liveOut; + return { + 'quote': { + if (!dry) 'depositAddress': _deposit, + 'amountIn': request['amount'], + 'amountOut': '$out', + 'minAmountOut': '${out * BigInt.from(99) ~/ BigInt.from(100)}', + 'amountInUsd': '25.0', + 'amountOutUsd': '24.86', + if (!dry) 'deadline': DateTime.now().toUtc().add(const Duration(minutes: 20)).toIso8601String(), + 'timeEstimate': 60, + }, + 'quoteRequest': { + 'slippageTolerance': request['slippageTolerance'], + 'refundTo': request['refundTo'], + 'recipient': request['recipient'], + 'deadline': request['deadline'], + }, + 'correlationId': dry ? 'dry' : 'live', + }; + } +} + +SwapQuote _quote({ + required SwapToken from, + required SwapToken to, + required BigInt amountIn, + required BigInt amountOut, + required String refund, + required String recipient, + String? depositAddress, +}) => SwapQuote( + fromToken: from, + toToken: to, + amountIn: amountIn, + amountOut: amountOut, + minAmountOut: amountOut * BigInt.from(99) ~/ BigInt.from(100), + amountInUsd: 25, + amountOutUsd: 24.86, + slippageBps: 100, + refundAddress: refund, + recipient: recipient, + deadline: DateTime.now().add(const Duration(minutes: 20)), + timeEstimate: const Duration(minutes: 1), + correlationId: 'c', + depositAddress: depositAddress, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final l10n = lookupAppLocalizations(const Locale('en')); + final account = makeAccount(1); + final outQuote = _quote( + from: _qtc, + to: _usdc, + amountIn: _unit * BigInt.from(250), + amountOut: BigInt.from(24860000), + refund: account.accountId, + recipient: _external, + ); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SettingsService().initialize(); + }); + + List overrides(SwapService service, {BigInt? balance, _FakeSubmission? submission}) => [ + settingsServiceProvider.overrideWithValue(FakeSettingsService(activeAccount: RegularAccount(account))), + isOnlineProvider.overrideWith((ref) => true), + l10nProvider.overrideWithValue(l10n), + swapServiceProvider.overrideWithValue(service), + quantusSwapTokenProvider.overrideWithValue(_qtc), + effectiveMaxBalanceProviderFamily.overrideWith((ref, _) => AsyncValue.data(balance ?? _unit * BigInt.from(1000))), + swapDepositFeeProvider.overrideWith((ref, _) async => _unit ~/ BigInt.from(50)), + balancesServiceProvider.overrideWithValue(FakeBalancesService()), + transactionSubmissionServiceProvider.overrideWithValue(submission ?? _FakeSubmission()), + swapOrderProvider.overrideWith((ref, order) => Stream.value(order)), + ]; + + Future settle(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + Finder svg(String asset) => + find.byWidgetPredicate((w) => w is SvgPicture && (w.bytesLoader as SvgAssetLoader).assetName == asset); + + QuantusButton button(WidgetTester tester, String label) => + tester.widget(find.ancestor(of: find.text(label), matching: find.byType(QuantusButton))); + + group('SwapScreen', () { + testWidgets('swaps out of QTC by default and flips direction with the arrows', (tester) async { + final oneClick = _OneClick(dryOut: BigInt.from(24860000)); + await tester.pumpApp( + SwapScreen(account: account), + overrides: overrides(oneClick.service(), balance: _unit * BigInt.from(100)), + ); + await settle(tester); + + expect(find.text('FROM'), findsOneWidget); + expect(find.text(account.name), findsOneWidget); + expect(find.text(l10n.swapExternalWallet), findsOneWidget); + expect(button(tester, l10n.swapAddRecipientAddress).isDisabled, isTrue); + + await tester.enterText(find.byType(TextField), '1000'); + await tester.pump(); + expect(button(tester, l10n.sendLogicInsufficientBalance).isDisabled, isTrue); + + await tester.enterText(find.byType(TextField), '10'); + await tester.pump(); + expect(button(tester, l10n.swapAddRecipientAddress).isDisabled, isFalse); + expect(find.text('1'), findsOneWidget); + + final arrows = svg('assets/v2/swap_arrows_down_up.svg'); + await tester.tap(arrows); + await tester.pump(); + + expect(button(tester, l10n.swapAddRefundAddress).isDisabled, isTrue); + final fromHeader = tester.getTopLeft(find.text('FROM')).dy; + expect(tester.getTopLeft(find.text(l10n.swapExternalWallet)).dy, closeTo(fromHeader, 4)); + }); + }); + + group('slippage', () { + testWidgets('the picked tolerance shows on the form and goes into the quote', (tester) async { + final oneClick = _OneClick(dryOut: BigInt.from(24860000)); + await tester.pumpApp(SwapScreen(account: account), overrides: overrides(oneClick.service())); + await settle(tester); + expect(find.text(l10n.swapSlippageLabel('1')), findsOneWidget); + + await tester.tap(svg('assets/v2/swap_pencil.svg')); + await settle(tester); + await tester.tap(find.text('2%')); + await settle(tester); + expect(find.text(l10n.swapSlippageLabel('2')), findsOneWidget); + + await tester.enterText(find.byType(TextField), '10'); + await tester.pump(); + await tester.tap(find.text(l10n.swapAddRecipientAddress)); + await settle(tester); + await tester.enterText(find.byType(TextField).last, _external); + await tester.pump(); + await tester.tap(find.text(l10n.swapContinue)); + await settle(tester); + + final quote = oneClick.requests.singleWhere((r) => r.url.path == '/v0/quote'); + expect((jsonDecode(quote.body) as Map)['slippageTolerance'], 200); + expect(find.text(l10n.swapReviewTitle), findsOneWidget); + }); + }); + + group('ReviewSwapScreen', () { + testWidgets('a swap out sends the QTC to the live deposit address and follows the swap', (tester) async { + final oneClick = _OneClick(dryOut: outQuote.amountOut); + final submission = _FakeSubmission(); + await tester.pumpApp( + ReviewSwapScreen(account: account, quote: outQuote), + overrides: overrides(oneClick.service(), submission: submission), + ); + await settle(tester); + + expect(find.text('RECIPIENT'), findsOneWidget); + expect(find.text('NETWORK FEE'), findsOneWidget); + expect(find.text('0.02 QTC'), findsOneWidget); + expect(find.text('24.6114 USDC'), findsOneWidget); + + await tester.tap(find.text(l10n.swapReviewConfirm)); + await settle(tester); + + expect(submission.transfers, [(_deposit, outQuote.amountIn, _unit ~/ BigInt.from(50))]); + final submit = oneClick.requests.singleWhere((r) => r.url.path == '/v0/deposit/submit'); + expect(jsonDecode(submit.body), {'txHash': '0xtxhash', 'depositAddress': _deposit}); + expect(find.text(l10n.swapInProgressTitle), findsOneWidget); + expect(find.text(l10n.swapStepSent('QTC')), findsOneWidget); + }); + + testWidgets('a worse live quote replaces the terms and needs a second confirm', (tester) async { + final oneClick = _OneClick(dryOut: outQuote.amountOut, liveOut: BigInt.from(20000000)); + final submission = _FakeSubmission(); + await tester.pumpApp( + ReviewSwapScreen(account: account, quote: outQuote), + overrides: overrides(oneClick.service(), submission: submission), + ); + await settle(tester); + + await tester.tap(find.text(l10n.swapReviewConfirm)); + await settle(tester); + + expect(submission.transfers, isEmpty); + expect(find.text(l10n.swapReviewTitle), findsOneWidget); + expect(find.text('19.8 USDC'), findsOneWidget); + + await tester.tap(find.text(l10n.swapReviewConfirm)); + await settle(tester); + + expect(submission.transfers.single.$1, _deposit); + expect(oneClick.liveQuotes, hasLength(1)); + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('blocks a swap out the balance cannot cover with its fee', (tester) async { + final oneClick = _OneClick(dryOut: outQuote.amountOut); + await tester.pumpApp( + ReviewSwapScreen(account: account, quote: outQuote), + overrides: overrides(oneClick.service(), balance: outQuote.amountIn), + ); + await settle(tester); + + expect(find.text(l10n.swapReviewInsufficient('QTC')), findsOneWidget); + expect(button(tester, l10n.swapReviewConfirm).isDisabled, isTrue); + }); + }); + + group('SwapProgressScreen', () { + Future pumpOrder(WidgetTester tester, SwapOrder order) async { + await tester.pumpApp( + SwapProgressScreen(account: account, order: order), + overrides: overrides(_OneClick(dryOut: BigInt.one).service()), + ); + await settle(tester); + } + + testWidgets('shows what arrived where once the swap succeeds', (tester) async { + await pumpOrder(tester, SwapOrder(quote: outQuote, status: SwapStatus.success, amountOut: BigInt.from(24900000))); + + expect(find.text(l10n.swapCompleteTitle), findsOneWidget); + expect(find.text('24.9 USDC'), findsOneWidget); + expect(find.text('Ethereum'), findsOneWidget); + }); + + testWidgets('tells a refunded swap out that the QTC went back to the account', (tester) async { + await pumpOrder(tester, SwapOrder(quote: outQuote, status: SwapStatus.refunded)); + + expect(find.text(l10n.swapFailedTitle), findsOneWidget); + expect(find.text('REFUNDED'), findsOneWidget); + expect(find.text(l10n.swapRefundedBody('250 QTC', account.name, 'USDC')), findsOneWidget); + expect(find.text(l10n.swapStartNew), findsOneWidget); + }); + + testWidgets('shows the deposit address and exact amount while a swap in waits', (tester) async { + final inQuote = _quote( + from: const SwapToken(assetId: 'nep141:btc', symbol: 'BTC', network: 'BTC', decimals: 8, usdPrice: 86000), + to: _qtc, + amountIn: BigInt.from(12345678), + amountOut: _unit * BigInt.from(1000), + refund: 'bc1qrefund', + recipient: account.accountId, + depositAddress: 'bc1qdeposit', + ); + await pumpOrder(tester, SwapOrder(quote: inQuote, status: SwapStatus.pendingDeposit)); + + expect(find.text('bc1qdeposit'), findsOneWidget); + expect(find.text('0.12345678'), findsOneWidget); + }); + }); +} diff --git a/mobile-app/test/unit/locale_number_handling_test.dart b/mobile-app/test/unit/locale_number_handling_test.dart index f583494ef..a3a51bc99 100644 --- a/mobile-app/test/unit/locale_number_handling_test.dart +++ b/mobile-app/test/unit/locale_number_handling_test.dart @@ -539,6 +539,17 @@ void main() { expect(usService.parseAmount('abc'), isNull); }); + test('a token with more decimals than the chain parses at full precision', () { + final expected = BigInt.parse('1123456789012345678901234'); + expect(usService.parseAmount('1.123456789012345678901234', decimals: 24), expected); + expect(idService.parseAmount('1,123456789012345678901234', decimals: 24), expected); + }); + + test('formatExactAmount keeps every significant digit', () { + expect(usService.formatExactAmount(BigInt.from(12345678), decimals: 8), '0.12345678'); + expect(idService.formatExactAmount(BigInt.from(1234567890), decimals: 8), '12,3456789'); + }); + test('Indonesian locale: integer without separators', () { expect(idService.parseAmount('1500'), scaleFactor * BigInt.from(1500)); }); From 3e6344143974b842708b9fc5c28022ad34c6e5cc Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 25 Sep 2026 22:45:34 +0800 Subject: [PATCH 07/11] Document swapping out of QTC through NEAR Intents --- mobile-app/docs/near-intents-swaps.md | 79 ++++++++++++++++++++------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md index dfd86855f..5c4927489 100644 --- a/mobile-app/docs/near-intents-swaps.md +++ b/mobile-app/docs/near-intents-swaps.md @@ -1,6 +1,8 @@ # NEAR Intents swaps -How the wallet swaps another chain's token into QTC through the NEAR Intents 1Click API, who sends what, and who waits on whom. +How the wallet swaps between QTC and another chain's token, in either direction, through the NEAR Intents 1Click API, who sends what, and who waits on whom. + +The origin chain is where the deposit goes in: the other chain when swapping into QTC, Quantus when swapping out of it. The destination chain is where the payout lands. Verified against the live API on 2026-09-20. There is no testnet for NEAR Intents. @@ -14,15 +16,15 @@ The 1Click API (`https://1click.chaindefuser.com`) is the REST front door to all | Actor | Where it runs | What it does | | --- | --- | --- | -| User | Their own origin-chain wallet (MetaMask, Phantom, a BTC wallet...) | Types the amount and a refund address, then sends the deposit from that wallet. | -| Quantus wallet | The app, `SwapService` in `quantus_sdk` | Asks 1Click for tokens, quotes and status. Passes the active account as the recipient. | +| User | The Quantus wallet, plus their own wallet on the other chain (MetaMask, Phantom, a BTC wallet...) | Swapping in: types the amount and a refund address, then sends the deposit from the other wallet. Swapping out: types the amount and a recipient address, then confirms. | +| Quantus wallet | The app, `SwapService` in `quantus_sdk` | Asks 1Click for tokens, quotes and status. Swapping in, the account is the recipient. Swapping out, the account is the refund address and the app signs the QTC deposit itself. | | 1Click API | NEAR Intents infrastructure | Auction, deposit addresses, chain watching, execution, payout, refunds, status. | | Solvers | Off-chain market makers connected to 1Click | Price the quote and fill the intent. | | Intents Verifier contract | NEAR | Settles the fill atomically. | -| Origin chain | ETH, BTC, SOL, BASE... (36 chains listed today) | Where the deposit lands and where refunds go. | -| Destination chain | Quantus | Where the payout would land. Not connected yet, see [Blockers](#blockers). | +| Other chain | ETH, BTC, SOL, BASE... (36 chains listed today) | The origin of a swap in, the destination of a swap out. | +| Quantus chain | Quantus | The destination of a swap in, the origin of a swap out. Not connected yet, see [Blockers](#blockers). | -## The happy path +## The happy path: swapping into QTC ```mermaid sequenceDiagram @@ -59,6 +61,42 @@ sequenceDiagram C-->>W: SUCCESS, destination tx hashes ``` +## The happy path: swapping out of QTC + +The same quote and status calls with origin and destination swapped. The difference is the deposit: the wallet sends it, so the user never leaves the app. + +```mermaid +sequenceDiagram + autonumber + actor U as User + participant W as Quantus wallet + participant C as 1Click API + participant Q as Quantus chain + participant N as NEAR Intents contract + participant D as Other chain + + U->>W: amount, destination token, recipient address + W->>C: POST /v0/quote (dry: true), refundTo = active account + C-->>W: amountOut, minAmountOut + W->>Q: payment_queryInfo for the deposit transfer + Note over W: Review: rate, recipient, network fee, slippage, guaranteed minimum + U->>W: Confirm swap + W->>C: POST /v0/quote (dry: false) + C-->>W: depositAddress on Quantus, deadline + Note over W: live minAmountOut below the reviewed one: show the new terms, confirm again + U->>W: device authentication + W->>Q: balances transfer of amountIn to depositAddress + W->>C: POST /v0/deposit/submit (txHash, depositAddress) + loop every 5 s until SUCCESS, REFUNDED or FAILED + W->>C: GET /v0/status?depositAddress=... + end + C->>N: publish intent, solver fills, settle + C->>D: withdraw amountOut to the recipient address + C-->>W: SUCCESS +``` + +The deposit is an ordinary signed transfer from the active account, so only transparent accounts whose key lives in the app can swap. Encrypted, Keystone and watch-only accounts see the swap button disabled. + ## Who waits on whom | Step | Who waits | On what | How long | What ends it | @@ -66,20 +104,21 @@ sequenceDiagram | Token list | App | 1Click | Under a second | Response. Cached for 10 minutes in the app. | | Dry quote | App | 1Click, which waits on solvers | 1 to 5 s (`quoteWaitingTimeMs` is 3000) | Best solver price, or "Failed to get quote" if no solver bids. | | Live quote | App | 1Click | Same as dry | Deposit address is reserved until `deadline`. | -| Deposit | 1Click, app polls | The user's origin-chain transaction | Up to the deadline, 20 minutes in our request | Deposit seen, or deadline passes. | +| Deposit | 1Click, app polls | Swapping in: the user's transaction on the other chain. Swapping out: the app's QTC transfer, handed to 1Click with `/v0/deposit/submit`. | Up to the deadline, 20 minutes in our request | Deposit seen, or deadline passes. | | Confirmation | 1Click | Origin-chain finality | Seconds on SOL, minutes on ETH, up to an hour on BTC. Docs say allow 15 min. | Enough confirmations. | | Settlement | 1Click | Solver fill and NEAR finality | Seconds | Intent settled. | | Payout | 1Click | Destination-chain withdrawal | Seconds to minutes | `SUCCESS` with `destinationChainTxHashes`. | -The app never blocks on anything but its own HTTP calls. The user waits on the deposit screen, and it is safe for them to leave it: 1Click continues regardless. Losing the screen does lose the deposit address in the app, see [Blockers](#blockers). +The app never blocks on anything but its own HTTP calls and, swapping out, the QTC transfer submission. The user waits on the progress screen, and it is safe for them to leave it: 1Click continues regardless. Losing the screen does lose the order in the app, see [Blockers](#blockers). ## Who sends what | Call | App sends | App reads back | | --- | --- | --- | | `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. QTC is dropped from the "from" list. | -| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance: 100` (1%), `originAsset`, `destinationAsset`, `amount` in base units, `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo`, `correlationId`. The request is echoed back in `quoteRequest`. | -| `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `destinationChainTxHashes[].hash`. | +| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo`, `correlationId`. The request is echoed back in `quoteRequest`. | +| `POST /v0/deposit/submit` | `txHash` of the QTC transfer, `depositAddress`, `memo` if any. Swapping out only. | nothing the app uses | +| `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `originChainTxHashes[].hash`, `destinationChainTxHashes[].hash`. | Amounts are strings of base units in both directions. The wallet parses user input with the origin token's decimals and formats every amount with its own token's decimals. @@ -123,21 +162,21 @@ stateDiagram-v2 | Screen or class | File | 1Click call | | --- | --- | --- | -| `SwapScreen` | `mobile-app/lib/v2/screens/swap/swap_screen.dart` | Tokens on open. Dry quote on "Get a Quote", recipient is the active account. | -| `ReviewQuoteSheet` | `mobile-app/lib/v2/screens/swap/review_quote_sheet.dart` | Live quote on Confirm, then opens the deposit screen with the order. | -| `DepositScreen` | `mobile-app/lib/v2/screens/swap/deposit_screen.dart` | Status every 5 s. One view per status, plus the expired view. | +| `SwapScreen` | `mobile-app/lib/v2/screens/swap/swap_screen.dart` | Tokens on open. One form for both directions; the arrows flip it. | +| Address sheet | `mobile-app/lib/v2/screens/swap/swap_address_sheet.dart` | Recipient (out) or refund (in) address, then the dry quote. Saved addresses are per network. | +| `ReviewSwapScreen` | `mobile-app/lib/v2/screens/swap/review_swap_screen.dart` | Live quote on Confirm, checked against the reviewed minimum. Swapping out, it also signs and sends the deposit and calls `/v0/deposit/submit`. | +| `SwapProgressScreen` | `mobile-app/lib/v2/screens/swap/swap_progress_screen.dart` | Status every 5 s through `swapOrderProvider`: the deposit address while a swap in waits, then progress steps, complete, or failed and refunded. | | `SwapService` | `quantus_sdk/lib/src/services/swap_service.dart` | The HTTP client. `SwapApiException` carries the server message. | | `SwapToken`, `SwapQuote`, `SwapOrder` | `quantus_sdk/lib/src/models/swap_*.dart` | Asset ids and BigInt base-unit amounts. | | Endpoints | `quantus_sdk/lib/src/constants/app_constants.dart` | `oneClickEndpoint`, `quantusIntentsAssetId`. | -Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request shape, response parsing, every status, and error handling against a mock HTTP client. +Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request shape, response parsing, every status, deposit submission and error handling against a mock HTTP client. `mobile-app/test/screens/swap_flow_test.dart` drives the form, the swap-out confirmation, the price-moved re-confirmation and the outcome screens. ## Blockers -1. **QTC is not listed on NEAR Intents.** The token list has 196 assets on 36 chains and none is Quantus. `AppConstants.quantusIntentsAssetId` is a placeholder, and every quote into it fails with `tokenOut is not valid`. Listing needs NEAR Intents to bridge the Quantus chain; that is a conversation with the NEAR Intents team, not a code change here. +1. **QTC is not listed on NEAR Intents.** The token list has 197 assets on 36 chains and none is Quantus. `AppConstants.quantusIntentsAssetId` is a placeholder, and every quote fails with `tokenOut is not valid` swapping in and `tokenIn is not valid` swapping out. Listing needs NEAR Intents to bridge the Quantus chain; that is a conversation with the NEAR Intents team, not a code change here. 2. **No partner key**, so every quote carries the extra 25 basis points. -3. **Orders are not persisted.** If the deposit screen is closed, the app has no record of the deposit address. The user still has it in the wallet they sent from, and 1Click keeps processing. -4. `POST /v0/deposit/submit` is not used. It only speeds up detection by handing 1Click the deposit tx hash. -5. The home swap button stays behind `AppConstants.showSwapButton = false`. -6. Only `EXACT_INPUT` with origin-chain deposits and destination-chain payout. Signed-intent execution would need the wallet to sign NEP-413 or similar payloads. -7. Slippage is fixed at 1%. +3. **Orders are not persisted.** If the progress screen is closed, the app has no record of the swap. A swap in's deposit address stays in the user's other wallet, a swap out's deposit shows as an ordinary transfer in activity, and 1Click keeps processing both. +4. The home swap button follows the `enableSwap` remote config flag and is enabled only for transparent accounts that sign in the app. +5. Only `EXACT_INPUT` with origin-chain deposits and destination-chain payout. Signed-intent execution would need the wallet to sign NEP-413 or similar payloads. +6. Slippage is picked from four presets (0.5, 1, 2, 3%); there is no custom value, and the pick is not persisted between launches. From cb52d04b2ed041e4558bd7e1f9ecf03642b575f3 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 09:44:54 +0800 Subject: [PATCH 08/11] Show deposit memos, size the deposit window by chain, use QTC's listing Address the review on the swap-out PR. A live quote's depositMemo is now shown on the deposit screen with its own copy action, a warning that a deposit without it is not processed, and a place in the shared text. Deposits from BTC, LTC, DOGE, BCH, DASH and ZEC get a two-hour window instead of twenty minutes, since their confirmations can take an hour. The pay side of the swap card and the details sheet print every digit of the amount that is sent. Once 1Click lists QTC under AppConstants.quantusIntentsAssetId, its asset id, decimals and price take over from the app's own QTC metadata. A listing whose decimals differ from the chain's is refused, since every quoted amount would then be wrong on chain. --- mobile-app/docs/near-intents-swaps.md | 8 +-- mobile-app/lib/l10n/app_en.arb | 30 ++++++++++++ mobile-app/lib/l10n/app_id.arb | 4 ++ mobile-app/lib/l10n/app_localizations.dart | 24 +++++++++ mobile-app/lib/l10n/app_localizations_en.dart | 14 ++++++ mobile-app/lib/l10n/app_localizations_id.dart | 14 ++++++ .../v2/screens/swap/swap_progress_screen.dart | 32 ++++++++++-- .../lib/v2/screens/swap/swap_providers.dart | 18 +++++-- .../lib/v2/screens/swap/swap_screen.dart | 12 +++-- .../lib/v2/screens/swap/swap_summary.dart | 4 +- mobile-app/test/screens/swap_flow_test.dart | 40 +++++++++++++++ quantus_sdk/lib/src/models/swap_token.dart | 1 + .../lib/src/services/swap_service.dart | 49 +++++++++++++++---- .../test/services/swap_service_test.dart | 46 ++++++++++++++++- 14 files changed, 270 insertions(+), 26 deletions(-) diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md index 5c4927489..4f454e09c 100644 --- a/mobile-app/docs/near-intents-swaps.md +++ b/mobile-app/docs/near-intents-swaps.md @@ -104,7 +104,7 @@ The deposit is an ordinary signed transfer from the active account, so only tran | Token list | App | 1Click | Under a second | Response. Cached for 10 minutes in the app. | | Dry quote | App | 1Click, which waits on solvers | 1 to 5 s (`quoteWaitingTimeMs` is 3000) | Best solver price, or "Failed to get quote" if no solver bids. | | Live quote | App | 1Click | Same as dry | Deposit address is reserved until `deadline`. | -| Deposit | 1Click, app polls | Swapping in: the user's transaction on the other chain. Swapping out: the app's QTC transfer, handed to 1Click with `/v0/deposit/submit`. | Up to the deadline, 20 minutes in our request | Deposit seen, or deadline passes. | +| Deposit | 1Click, app polls | Swapping in: the user's transaction on the other chain. Swapping out: the app's QTC transfer, handed to 1Click with `/v0/deposit/submit`. | Up to the deadline: 20 minutes in our request, 2 hours from BTC, LTC, DOGE, BCH, DASH and ZEC, whose confirmations take longer than that | Deposit seen, or deadline passes. | | Confirmation | 1Click | Origin-chain finality | Seconds on SOL, minutes on ETH, up to an hour on BTC. Docs say allow 15 min. | Enough confirmations. | | Settlement | 1Click | Solver fill and NEAR finality | Seconds | Intent settled. | | Payout | 1Click | Destination-chain withdrawal | Seconds to minutes | `SUCCESS` with `destinationChainTxHashes`. | @@ -115,8 +115,8 @@ The app never blocks on anything but its own HTTP calls and, swapping out, the Q | Call | App sends | App reads back | | --- | --- | --- | -| `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. QTC is dropped from the "from" list. | -| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo`, `correlationId`. The request is echoed back in `quoteRequest`. | +| `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. The entry whose `assetId` is `AppConstants.quantusIntentsAssetId` is QTC as 1Click lists it and takes over from the app's own QTC metadata; it is refused if its decimals differ from the chain's. | +| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`. The request is echoed back in `quoteRequest`. | | `POST /v0/deposit/submit` | `txHash` of the QTC transfer, `depositAddress`, `memo` if any. Swapping out only. | nothing the app uses | | `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `originChainTxHashes[].hash`, `destinationChainTxHashes[].hash`. | @@ -174,7 +174,7 @@ Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request sha ## Blockers -1. **QTC is not listed on NEAR Intents.** The token list has 197 assets on 36 chains and none is Quantus. `AppConstants.quantusIntentsAssetId` is a placeholder, and every quote fails with `tokenOut is not valid` swapping in and `tokenIn is not valid` swapping out. Listing needs NEAR Intents to bridge the Quantus chain; that is a conversation with the NEAR Intents team, not a code change here. +1. **QTC is not listed on NEAR Intents.** The token list has 197 assets on 36 chains and none is Quantus. `AppConstants.quantusIntentsAssetId` is a placeholder, and every quote fails with `tokenOut is not valid` swapping in and `tokenIn is not valid` swapping out. Listing needs NEAR Intents to bridge the Quantus chain; that is a conversation with the NEAR Intents team. Once they list it, set `quantusIntentsAssetId` to the real id: the app then reads QTC's decimals and price from the listing instead of its own metadata. Swap stays behind the `enableSwap` remote config flag until swaps have been tested against the live listing. 2. **No partner key**, so every quote carries the extra 25 basis points. 3. **Orders are not persisted.** If the progress screen is closed, the app has no record of the swap. A swap in's deposit address stays in the user's other wallet, a swap out's deposit shows as an ordinary transfer in activity, and 1Click keeps processing both. 4. The home swap button follows the `enableSwap` remote config flag and is enabled only for transparent accounts that sign in the app. diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 0e3b09f79..e14300fb5 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -2713,6 +2713,36 @@ } } }, + "swapDepositMemo": "Memo", + "@swapDepositMemo": { + "description": "Label of the deposit memo some chains require" + }, + "swapDepositMemoCopied": "Deposit memo copied to clipboard", + "@swapDepositMemoCopied": { + "description": "Toast when the deposit memo is copied" + }, + "swapDepositMemoNotice": "Include this memo with your deposit. A deposit without it is not processed.", + "@swapDepositMemoNotice": { + "description": "Warning under a deposit memo" + }, + "swapDepositShareContentMemo": "Network: {network}\nToken: {token}\nAddress: {address}\nMemo: {memo}", + "@swapDepositShareContentMemo": { + "description": "Share text for deposit details with a memo", + "placeholders": { + "network": { + "type": "String" + }, + "token": { + "type": "String" + }, + "address": { + "type": "String" + }, + "memo": { + "type": "String" + } + } + }, "swapDepositNotice": "Use your {symbol} or {network} wallet to deposit funds. Depositing other assets may result in loss of funds.", "@swapDepositNotice": { "description": "Deposit wallet notice on deposit screen", diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index 40e382e48..161efd829 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -520,6 +520,10 @@ "swapDepositDeadline": "Kirim sebelum {time} atau penawaran kedaluwarsa", "swapDepositShareQr": "Bagikan QR", "swapDepositShareContent": "Jaringan: {network}\nToken: {token}\nAlamat: {address}", + "swapDepositMemo": "Memo", + "swapDepositMemoCopied": "Memo deposit disalin ke clipboard", + "swapDepositMemoNotice": "Sertakan memo ini pada deposit Anda. Deposit tanpa memo tidak akan diproses.", + "swapDepositShareContentMemo": "Jaringan: {network}\nToken: {token}\nAlamat: {address}\nMemo: {memo}", "swapDepositNotice": "Gunakan dompet {symbol} atau {network} Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.", "componentQrScannerTitle": "Pindai Kode QR", "componentQrScannerNoCode": "Tidak ada kode QR pada gambar", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 55c7bc155..587ad4718 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -3446,6 +3446,30 @@ abstract class AppLocalizations { /// **'Network: {network}\nToken: {token}\nAddress: {address}'** String swapDepositShareContent(String network, String token, String address); + /// Label of the deposit memo some chains require + /// + /// In en, this message translates to: + /// **'Memo'** + String get swapDepositMemo; + + /// Toast when the deposit memo is copied + /// + /// In en, this message translates to: + /// **'Deposit memo copied to clipboard'** + String get swapDepositMemoCopied; + + /// Warning under a deposit memo + /// + /// In en, this message translates to: + /// **'Include this memo with your deposit. A deposit without it is not processed.'** + String get swapDepositMemoNotice; + + /// Share text for deposit details with a memo + /// + /// In en, this message translates to: + /// **'Network: {network}\nToken: {token}\nAddress: {address}\nMemo: {memo}'** + String swapDepositShareContentMemo(String network, String token, String address, String memo); + /// Deposit wallet notice on deposit screen /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 823a55e7e..c4e622857 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -1853,6 +1853,20 @@ class AppLocalizationsEn extends AppLocalizations { return 'Network: $network\nToken: $token\nAddress: $address'; } + @override + String get swapDepositMemo => 'Memo'; + + @override + String get swapDepositMemoCopied => 'Deposit memo copied to clipboard'; + + @override + String get swapDepositMemoNotice => 'Include this memo with your deposit. A deposit without it is not processed.'; + + @override + String swapDepositShareContentMemo(String network, String token, String address, String memo) { + return 'Network: $network\nToken: $token\nAddress: $address\nMemo: $memo'; + } + @override String swapDepositNotice(String symbol, String network) { return 'Use your $symbol or $network wallet to deposit funds. Depositing other assets may result in loss of funds.'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index a063cd57d..b1810bb58 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -1856,6 +1856,20 @@ class AppLocalizationsId extends AppLocalizations { return 'Jaringan: $network\nToken: $token\nAlamat: $address'; } + @override + String get swapDepositMemo => 'Memo'; + + @override + String get swapDepositMemoCopied => 'Memo deposit disalin ke clipboard'; + + @override + String get swapDepositMemoNotice => 'Sertakan memo ini pada deposit Anda. Deposit tanpa memo tidak akan diproses.'; + + @override + String swapDepositShareContentMemo(String network, String token, String address, String memo) { + return 'Jaringan: $network\nToken: $token\nAlamat: $address\nMemo: $memo'; + } + @override String swapDepositNotice(String symbol, String network) { return 'Gunakan dompet $symbol atau $network Anda untuk deposit. Menyetor aset lain dapat mengakibatkan kehilangan dana.'; diff --git a/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart index a05968806..ae249b1ff 100644 --- a/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_progress_screen.dart @@ -169,7 +169,7 @@ class _SwapDetailsSheet extends ConsumerWidget { ), SwapDetailRow( label: l10n.swapDetailsAmountSent, - value: formatSwapAmount(l10n, fmt, quote.amountIn, quote.fromToken), + value: formatSwapAmount(l10n, fmt, quote.amountIn, quote.fromToken, exact: true), ), if (fee != null) SwapDetailRow(label: l10n.swapReviewNetworkFee, value: formatSwapAmount(l10n, fmt, fee, quote.fromToken)), @@ -357,8 +357,12 @@ class _SwapDeposit extends ConsumerWidget { final quote = order.quote; final from = quote.fromToken; final address = order.depositAddress; + final memo = quote.depositMemo; final deadline = TimeOfDay.fromDateTime(quote.deadline.toLocal()).format(context); Future copyAddress() => context.copyTextWithToaster(address, message: l10n.swapDepositAddressCopied); + final shareContent = memo == null + ? l10n.swapDepositShareContent(from.networkName, from.symbol, address) + : l10n.swapDepositShareContentMemo(from.networkName, from.symbol, address, memo); return ScaffoldBase( appBar: V2AppBar(title: l10n.swapTitle), @@ -422,6 +426,29 @@ class _SwapDeposit extends ConsumerWidget { textAlign: TextAlign.center, ), ), + if (memo != null) ...[ + const SizedBox(height: 16), + GestureDetector( + onTap: () => context.copyTextWithToaster(memo, message: l10n.swapDepositMemoCopied), + behavior: HitTestBehavior.opaque, + child: Column( + children: [ + Text( + l10n.swapDepositMemo.toUpperCase(), + style: text.labelMonogram.copyWith(color: colors.textMuted), + ), + const SizedBox(height: 6), + Text( + memo, + style: text.dataAddressLarge.copyWith(color: colors.textContent), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 12), + QuantusBanner(tone: BannerTone.sand, message: l10n.swapDepositMemoNotice), + ], const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -458,8 +485,7 @@ class _SwapDeposit extends ConsumerWidget { iconPlacement: IconPlacement.leading, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), variant: ButtonVariant.staged, - onTap: () => - shareText(context, l10n.swapDepositShareContent(from.networkName, from.symbol, address)), + onTap: () => shareText(context, shareContent), ), ), ], diff --git a/mobile-app/lib/v2/screens/swap/swap_providers.dart b/mobile-app/lib/v2/screens/swap/swap_providers.dart index ae0943ff5..65bdfe0ff 100644 --- a/mobile-app/lib/v2/screens/swap/swap_providers.dart +++ b/mobile-app/lib/v2/screens/swap/swap_providers.dart @@ -16,7 +16,8 @@ const swapSlippageOptionsBps = [50, 100, 200, 300]; /// Slippage tolerance the next quote asks for, in basis points. final swapSlippageBpsProvider = StateProvider((_) => SwapService.defaultSlippageBps); -/// QTC as a swap token, priced with the same rate the rest of the app uses. +/// QTC from the app's own metadata, priced with the rate the rest of the app +/// uses. Stands in until 1Click lists QTC; the listing then takes over. final quantusSwapTokenProvider = Provider( (ref) => SwapService.quantusToken(usdPrice: ref.watch(exchangeRateServiceProvider).tokenToUsdRate.toDouble()), ); @@ -66,9 +67,18 @@ BigInt swapEstimateOut(BigInt amountIn, SwapToken from, SwapToken to) { BigInt swapQuoteRate(SwapQuote quote) => quote.amountOut * BigInt.from(10).pow(quote.fromToken.decimals) ~/ quote.amountIn; -/// [value] base units of [token] with its symbol, e.g. "24.86 USDC". -String formatSwapAmount(AppLocalizations l10n, NumberFormattingService fmt, BigInt value, SwapToken token) => - l10n.commonAmountBalance(fmt.formatAmount(value, decimals: token.decimals), token.symbol); +/// [value] base units of [token] with its symbol, e.g. "24.86 USDC". An +/// [exact] amount keeps every digit: what is sent must read as what is shown. +String formatSwapAmount( + AppLocalizations l10n, + NumberFormattingService fmt, + BigInt value, + SwapToken token, { + bool exact = false, +}) => l10n.commonAmountBalance( + exact ? fmt.formatExactAmount(value, decimals: token.decimals) : fmt.formatAmount(value, decimals: token.decimals), + token.symbol, +); String describeSwapError(Object error) => error is SwapApiException ? error.message : error.toString(); diff --git a/mobile-app/lib/v2/screens/swap/swap_screen.dart b/mobile-app/lib/v2/screens/swap/swap_screen.dart index 61218ffc8..9efd2a356 100644 --- a/mobile-app/lib/v2/screens/swap/swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/swap_screen.dart @@ -34,6 +34,7 @@ class _SwapScreenState extends ConsumerState { final _amountController = TextEditingController(); SwapToken? _foreign; + SwapToken? _listedQuantus; bool _loadingTokens = true; bool _swapOut = true; @@ -53,9 +54,14 @@ class _SwapScreenState extends ConsumerState { Future _loadTokens({bool forceRefresh = false}) async { setState(() => _loadingTokens = true); try { - final tokens = await ref.read(swapServiceProvider).getFromTokens(forceRefresh: forceRefresh); + final service = ref.read(swapServiceProvider); + final tokens = await service.getFromTokens(forceRefresh: forceRefresh); + final listedQuantus = await service.getListedQuantusToken(); if (!mounted) return; - setState(() => _foreign ??= tokens.first); + setState(() { + _foreign ??= tokens.first; + _listedQuantus = listedQuantus; + }); } catch (e) { quantusPrint('Swap tokens failed to load: $e'); } finally { @@ -120,7 +126,7 @@ class _SwapScreenState extends ConsumerState { final colors = context.colorsV3; final text = context.themeTextV3; final foreign = _foreign; - final quantus = ref.watch(quantusSwapTokenProvider); + final SwapToken quantus = _listedQuantus ?? ref.watch(quantusSwapTokenProvider); return ScaffoldBase( appBar: V2AppBar( diff --git a/mobile-app/lib/v2/screens/swap/swap_summary.dart b/mobile-app/lib/v2/screens/swap/swap_summary.dart index b36b7ee1a..c8046bdd8 100644 --- a/mobile-app/lib/v2/screens/swap/swap_summary.dart +++ b/mobile-app/lib/v2/screens/swap/swap_summary.dart @@ -37,7 +37,7 @@ class SwapAmountsCard extends ConsumerWidget { children: [ Column( children: [ - _row(context, ref, quote.fromToken, quote.amountIn, payLabel, dimmed: false), + _row(context, ref, quote.fromToken, quote.amountIn, payLabel, dimmed: false, exact: true), Container(height: 4, color: colors.bgVoid), _row(context, ref, quote.toToken, amountOut ?? quote.amountOut, receiveLabel, dimmed: receiveDimmed), ], @@ -61,6 +61,7 @@ class SwapAmountsCard extends ConsumerWidget { BigInt amount, String label, { required bool dimmed, + bool exact = false, }) { final colors = context.colorsV3; final text = context.themeTextV3; @@ -86,6 +87,7 @@ class SwapAmountsCard extends ConsumerWidget { ref.watch(numberFormattingServiceProvider), amount, token, + exact: exact, ), style: text.amountHero.copyWith(color: dimmed ? colors.textMuted2 : colors.textContent), ), diff --git a/mobile-app/test/screens/swap_flow_test.dart b/mobile-app/test/screens/swap_flow_test.dart index 51ef755b7..1d69746da 100644 --- a/mobile-app/test/screens/swap_flow_test.dart +++ b/mobile-app/test/screens/swap_flow_test.dart @@ -108,6 +108,7 @@ SwapQuote _quote({ required String refund, required String recipient, String? depositAddress, + String? depositMemo, }) => SwapQuote( fromToken: from, toToken: to, @@ -123,6 +124,7 @@ SwapQuote _quote({ timeEstimate: const Duration(minutes: 1), correlationId: 'c', depositAddress: depositAddress, + depositMemo: depositMemo, ); void main() { @@ -279,6 +281,25 @@ void main() { await tester.pump(const Duration(seconds: 3)); }); + testWidgets('shows every digit of the amount that will be sent', (tester) async { + final oneClick = _OneClick(dryOut: outQuote.amountOut); + final quote = _quote( + from: _qtc, + to: _usdc, + amountIn: _unit + BigInt.from(90000000), + amountOut: outQuote.amountOut, + refund: account.accountId, + recipient: _external, + ); + await tester.pumpApp( + ReviewSwapScreen(account: account, quote: quote), + overrides: overrides(oneClick.service()), + ); + await settle(tester); + + expect(find.text('1.00009 QTC'), findsOneWidget); + }); + testWidgets('blocks a swap out the balance cannot cover with its fee', (tester) async { final oneClick = _OneClick(dryOut: outQuote.amountOut); await tester.pumpApp( @@ -332,6 +353,25 @@ void main() { expect(find.text('bc1qdeposit'), findsOneWidget); expect(find.text('0.12345678'), findsOneWidget); + expect(find.text(l10n.swapDepositMemoNotice), findsNothing); + }); + + testWidgets('shows the memo a deposit must carry', (tester) async { + final inQuote = _quote( + from: const SwapToken(assetId: 'nep245:xlm', symbol: 'XLM', network: 'STELLAR', decimals: 7, usdPrice: 0.2), + to: _qtc, + amountIn: BigInt.from(50000000), + amountOut: _unit * BigInt.from(10), + refund: 'GREFUND', + recipient: account.accountId, + depositAddress: 'GDEPOSIT', + depositMemo: '4183920', + ); + await pumpOrder(tester, SwapOrder(quote: inQuote, status: SwapStatus.pendingDeposit)); + + expect(find.text('MEMO'), findsOneWidget); + expect(find.text('4183920'), findsOneWidget); + expect(find.text(l10n.swapDepositMemoNotice), findsOneWidget); }); }); } diff --git a/quantus_sdk/lib/src/models/swap_token.dart b/quantus_sdk/lib/src/models/swap_token.dart index db5283dcc..afe17a214 100644 --- a/quantus_sdk/lib/src/models/swap_token.dart +++ b/quantus_sdk/lib/src/models/swap_token.dart @@ -17,6 +17,7 @@ class SwapToken { 'MONAD': 'Monad', 'OP': 'Optimism', 'POL': 'Polygon', + 'QUANTUS': 'Quantus', 'SOL': 'Solana', 'STELLAR': 'Stellar', 'SUI': 'Sui', diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index dce483925..dc9f19b99 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -26,6 +26,11 @@ class SwapApiException implements Exception { class SwapService { static const defaultSlippageBps = 100; static const depositWindow = Duration(minutes: 20); + + /// Chains whose blocks take minutes and need several confirmations, so a + /// correctly sent deposit can take an hour to count. + static const _slowNetworks = {'BTC', 'LTC', 'DOGE', 'BCH', 'DASH', 'ZEC'}; + static const _slowDepositWindow = Duration(hours: 2); static const quoteWaitingTime = Duration(seconds: 3); static const statusPollInterval = Duration(seconds: 5); static const _savedAddressesKey = 'swap_saved_addresses'; @@ -39,12 +44,18 @@ class SwapService { final String? _apiKey; List? _cachedFromTokens; DateTime? _cachedFromTokensAt; + SwapToken? _listedQuantus; SwapService({http.Client? client, String endpoint = AppConstants.oneClickEndpoint, String? apiKey}) : _client = client ?? http.Client(), _base = Uri.parse(endpoint), _apiKey = apiKey; + /// How long a deposit on [network] has before its quote expires. + static Duration depositWindowFor(String network) => + _slowNetworks.contains(network) ? _slowDepositWindow : depositWindow; + + /// QTC from the app's own metadata, for use until 1Click lists it. static SwapToken quantusToken({required double usdPrice}) => SwapToken( assetId: AppConstants.quantusIntentsAssetId, symbol: AppConstants.tokenSymbol, @@ -53,16 +64,31 @@ class SwapService { usdPrice: usdPrice, ); - Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async { + Future> getFromTokens({int limit = 10, bool forceRefresh = false}) async => + (await _tokens(forceRefresh: forceRefresh)).take(limit).toList(); + + /// QTC as 1Click lists it, with its asset id, decimals and price; null until + /// it is listed. A listing whose decimals differ from the chain's would make + /// every quoted amount wrong on chain, so it is refused. + Future getListedQuantusToken({bool forceRefresh = false}) async { + await _tokens(forceRefresh: forceRefresh); + final listed = _listedQuantus; + if (listed != null && listed.decimals != AppConstants.decimals) { + throw StateError( + '1Click lists ${listed.assetId} with ${listed.decimals} decimals, the chain has ${AppConstants.decimals}', + ); + } + return listed; + } + + Future> _tokens({required bool forceRefresh}) async { final now = DateTime.now(); final cached = _cachedFromTokens; - if (!forceRefresh && cached != null && now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) { - return cached.take(limit).toList(); - } + if (!forceRefresh && cached != null && now.difference(_cachedFromTokensAt!) < _tokensCacheTtl) return cached; final tokens = await _rankByCoinGecko(await _fetchIntentsTokens()); _cachedFromTokens = tokens; _cachedFromTokensAt = now; - return tokens.take(limit).toList(); + return tokens; } /// Asks solvers for a price on [amount] base units of [from]. A dry quote is @@ -91,7 +117,7 @@ class SwapService { 'refundType': 'ORIGIN_CHAIN', 'recipient': recipient, 'recipientType': 'DESTINATION_CHAIN', - 'deadline': DateTime.now().toUtc().add(depositWindow).toIso8601String(), + 'deadline': DateTime.now().toUtc().add(depositWindowFor(from.network)).toIso8601String(), 'quoteWaitingTimeMs': quoteWaitingTime.inMilliseconds, }, ); @@ -161,19 +187,22 @@ class SwapService { Future> _fetchIntentsTokens() async { final data = await _send('GET', '/v0/tokens') as List; final bySymbol = {}; + _listedQuantus = null; for (final item in data.cast>()) { - final price = (item['price'] as num?)?.toDouble() ?? 0; - if (price <= 0) continue; final network = (item['blockchain'] as String).toUpperCase(); final token = SwapToken( assetId: item['assetId'] as String, symbol: (item['symbol'] as String).toUpperCase(), network: network, decimals: (item['decimals'] as num).toInt(), - usdPrice: price, + usdPrice: (item['price'] as num?)?.toDouble() ?? 0, networkIconUrl: _networkIconUrl(network), ); - if (token.symbol == AppConstants.tokenSymbol) continue; + if (token.isQuantus) { + _listedQuantus = token; + continue; + } + if (token.usdPrice <= 0 || token.symbol == AppConstants.tokenSymbol) continue; final existing = bySymbol[token.symbol]; if (existing == null || _networkPriority(token.network) < _networkPriority(existing.network)) { bySymbol[token.symbol] = token; diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index c0ca8cbdc..73ea6567a 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -121,6 +121,21 @@ void main() { expect(quote.depositAddress, isNull); }); + test('gives a deposit from a slow chain two hours', () async { + late Map sent; + final before = DateTime.now().toUtc(); + final service = _service((r) async { + sent = jsonDecode(r.body) as Map; + return http.Response(jsonEncode(_quoteResponse()), 200); + }); + const btc = SwapToken(assetId: 'nep141:btc.omft.near', symbol: 'BTC', network: 'BTC', decimals: 8, usdPrice: 1); + await service.getQuote(from: btc, to: _wnear, amount: BigInt.one, refundAddress: _refund, recipient: _recipient); + final deadline = DateTime.parse(sent['deadline'] as String); + expect(SwapService.depositWindowFor('BTC'), const Duration(hours: 2)); + expect(deadline.difference(before), greaterThanOrEqualTo(const Duration(hours: 2))); + expect(deadline.difference(before), lessThan(const Duration(hours: 2, seconds: 10))); + }); + test('sends no API key header when none is configured', () async { late http.Request request; final service = _service((r) async { @@ -322,9 +337,38 @@ void main() { {'assetId': _usdcEth.assetId, 'decimals': 6, 'blockchain': 'eth', 'symbol': 'USDC', 'price': 0.99966}, {'assetId': 'nep141:btc.omft.near', 'decimals': 8, 'blockchain': 'btc', 'symbol': 'BTC', 'price': 80496}, {'assetId': 'nep141:dead.omft.near', 'decimals': 18, 'blockchain': 'eth', 'symbol': 'DEAD', 'price': 0}, - {'assetId': 'nep141:qtc.omft.near', 'decimals': 12, 'blockchain': 'eth', 'symbol': 'QTC', 'price': 1}, + {'assetId': 'nep141:qtc.omft.near', 'decimals': 12, 'blockchain': 'quantus', 'symbol': 'QTC', 'price': 1.5}, + {'assetId': 'nep141:other-qtc.omft.near', 'decimals': 18, 'blockchain': 'eth', 'symbol': 'QTC', 'price': 9}, ]; + SwapService listing(List> list) => _service((r) async { + if (r.url.host == 'api.coingecko.com') return http.Response('down', 503); + return http.Response(jsonEncode(list), 200); + }); + + test('takes QTC from the listing by asset id and keeps it out of the other tokens', () async { + final service = listing(tokens); + final quantus = await service.getListedQuantusToken(); + expect(quantus, isNotNull); + expect(quantus!.assetId, AppConstants.quantusIntentsAssetId); + expect(quantus.isQuantus, isTrue); + expect(quantus.networkName, 'Quantus'); + expect(quantus.usdPrice, 1.5); + expect((await service.getFromTokens()).map((t) => t.symbol), isNot(contains('QTC'))); + }); + + test('has no listed QTC until 1Click adds it', () async { + final service = listing(tokens.where((t) => t['symbol'] != 'QTC').toList()); + expect(await service.getListedQuantusToken(), isNull); + }); + + test('refuses a QTC listing whose decimals differ from the chain', () async { + final service = listing([ + {'assetId': 'nep141:qtc.omft.near', 'decimals': 18, 'blockchain': 'quantus', 'symbol': 'QTC', 'price': 1}, + ]); + await expectLater(service.getListedQuantusToken(), throwsA(isA())); + }); + test('keeps one asset per symbol, preferring the main network, ranked by CoinGecko', () async { final service = _service((r) async { if (r.url.host == 'api.coingecko.com') { From 3e46fc1e2633edd8895ba69d8c0fbfe70133d613 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 10:13:28 +0800 Subject: [PATCH 09/11] Match the 1Click quote contract: deposit mode, referral, saved signatures Checked against the 1Click OpenAPI spec (v0.1.10) and live dry quotes. Stellar refuses a quote unless depositMode is MEMO, and every other chain refuses MEMO, so the request names the mode per origin chain. Every quote carries referral "quantus", the distribution channel id 1Click records. SwapQuote keeps 1Click's signature over the quote, and the signed response of every live quote is saved on the device, which the spec requires for settling a dispute about a deposit address. --- mobile-app/docs/near-intents-swaps.md | 4 +- mobile-app/test/screens/swap_flow_test.dart | 3 + .../lib/src/constants/app_constants.dart | 3 + quantus_sdk/lib/src/models/swap_quote.dart | 5 ++ .../lib/src/services/swap_service.dart | 77 +++++++++++++++---- .../test/services/swap_service_test.dart | 27 +++++++ 6 files changed, 101 insertions(+), 18 deletions(-) diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md index 4f454e09c..5c64bdcda 100644 --- a/mobile-app/docs/near-intents-swaps.md +++ b/mobile-app/docs/near-intents-swaps.md @@ -116,7 +116,7 @@ The app never blocks on anything but its own HTTP calls and, swapping out, the Q | Call | App sends | App reads back | | --- | --- | --- | | `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. The entry whose `assetId` is `AppConstants.quantusIntentsAssetId` is QTC as 1Click lists it and takes over from the app's own QTC metadata; it is refused if its decimals differ from the chain's. | -| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`. The request is echoed back in `quoteRequest`. | +| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `depositType: ORIGIN_CHAIN`, `depositMode` (`MEMO` for a Stellar origin, which 1Click refuses to quote otherwise, `SIMPLE` everywhere else), `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`, `referral: quantus`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`, `signature`. The signed response of every live quote is kept on the device (`SwapService.getSavedLiveQuotes`, last 50): 1Click settles a dispute about a deposit address from it. The request is echoed back in `quoteRequest`. | | `POST /v0/deposit/submit` | `txHash` of the QTC transfer, `depositAddress`, `memo` if any. Swapping out only. | nothing the app uses | | `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `originChainTxHashes[].hash`, `destinationChainTxHashes[].hash`. | @@ -153,7 +153,7 @@ stateDiagram-v2 ## Fees -- Without a partner key 1Click adds 25 basis points to every quote. The live probe echoed `appFees: [{recipient: 5880ad2b..., fee: 25}]` on a request that sent none. +- Without a partner key 1Click adds its platform fee to every quote: 25 basis points per the docs, 20 in the live probes of 2026-09-26, echoed as `appFees: [{recipient: 5880ad2b..., fee: 20}]` on a request that sent none. - With a partner key from partners.near-intents.org the platform fee is 20 basis points, 1 basis point on stablecoin and same-asset routes. Pass it as `SwapService(apiKey:)`; it goes out as `X-API-Key`. - Fees are inside `amountOut`. Nothing is charged on top of `amountIn`. - `refundFee` and `withdrawFee` in the quote are in base units of the origin asset. diff --git a/mobile-app/test/screens/swap_flow_test.dart b/mobile-app/test/screens/swap_flow_test.dart index 1d69746da..4d4c90850 100644 --- a/mobile-app/test/screens/swap_flow_test.dart +++ b/mobile-app/test/screens/swap_flow_test.dart @@ -96,6 +96,8 @@ class _OneClick { 'deadline': request['deadline'], }, 'correlationId': dry ? 'dry' : 'live', + 'signature': 'ed25519:sig', + 'timestamp': DateTime.now().toUtc().toIso8601String(), }; } } @@ -123,6 +125,7 @@ SwapQuote _quote({ deadline: DateTime.now().add(const Duration(minutes: 20)), timeEstimate: const Duration(minutes: 1), correlationId: 'c', + signature: 'ed25519:sig', depositAddress: depositAddress, depositMemo: depositMemo, ); diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index ffe990765..0f2cd6c9b 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -39,6 +39,9 @@ class AppConstants { // NEAR Intents 1Click swap API (docs.near-intents.org). static const String oneClickEndpoint = 'https://1click.chaindefuser.com'; + // Distribution channel id 1Click records on every quote. + static const String oneClickReferral = 'quantus'; + // Quantus is not listed on NEAR Intents yet, so every quote into it is // rejected as "tokenOut is not valid". Replace with the listed asset id. static const String quantusIntentsAssetId = 'nep141:qtc.omft.near'; diff --git a/quantus_sdk/lib/src/models/swap_quote.dart b/quantus_sdk/lib/src/models/swap_quote.dart index 24edd4223..e7da77df5 100644 --- a/quantus_sdk/lib/src/models/swap_quote.dart +++ b/quantus_sdk/lib/src/models/swap_quote.dart @@ -17,6 +17,9 @@ class SwapQuote { final DateTime deadline; final Duration timeEstimate; final String correlationId; + + /// 1Click's signature over the request and response; what settles a dispute. + final String signature; final String? depositAddress; final String? depositMemo; @@ -34,6 +37,7 @@ class SwapQuote { required this.deadline, required this.timeEstimate, required this.correlationId, + required this.signature, this.depositAddress, this.depositMemo, }); @@ -55,6 +59,7 @@ class SwapQuote { deadline: DateTime.parse((quote['deadline'] ?? request['deadline']) as String), timeEstimate: Duration(seconds: (quote['timeEstimate'] as num).round()), correlationId: json['correlationId'] as String, + signature: json['signature'] as String, depositAddress: quote['depositAddress'] as String?, depositMemo: quote['depositMemo'] as String?, ); diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index dc9f19b99..6ae397bd4 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -31,6 +31,12 @@ class SwapService { /// correctly sent deposit can take an hour to count. static const _slowNetworks = {'BTC', 'LTC', 'DOGE', 'BCH', 'DASH', 'ZEC'}; static const _slowDepositWindow = Duration(hours: 2); + + /// Chains whose deposits carry a memo; 1Click rejects a plain deposit + /// address for them and a memo request for every other chain. + static const _memoNetworks = {'STELLAR'}; + static const _liveQuotesKey = 'swap_live_quotes'; + static const _maxLiveQuotes = 50; static const quoteWaitingTime = Duration(seconds: 3); static const statusPollInterval = Duration(seconds: 5); static const _savedAddressesKey = 'swap_saved_addresses'; @@ -101,6 +107,58 @@ class SwapService { required String recipient, int slippageBps = defaultSlippageBps, bool dry = true, + }) async { + final json = await _quoteJson( + from: from, + to: to, + amount: amount, + refundAddress: refundAddress, + recipient: recipient, + slippageBps: slippageBps, + dry: dry, + ); + return SwapQuote.fromJson(json, fromToken: from, toToken: to); + } + + /// Re-quotes [quote] live so 1Click reserves a deposit address for it. The + /// signed response is kept on the device: 1Click settles any dispute about + /// a deposit address from it. + Future createSwap(SwapQuote quote) async { + final json = await _quoteJson( + from: quote.fromToken, + to: quote.toToken, + amount: quote.amountIn, + refundAddress: quote.refundAddress, + recipient: quote.recipient, + slippageBps: quote.slippageBps, + dry: false, + ); + final live = SwapQuote.fromJson(json, fromToken: quote.fromToken, toToken: quote.toToken); + if (live.depositAddress == null) throw StateError('Live quote ${live.correlationId} has no deposit address'); + await _saveLiveQuote(json); + return SwapOrder(quote: live, status: SwapStatus.pendingDeposit); + } + + /// Signed live quote responses, most recent first. + Future>> getSavedLiveQuotes() async { + final prefs = await SharedPreferences.getInstance(); + return [for (final s in prefs.getStringList(_liveQuotesKey) ?? []) jsonDecode(s) as Map]; + } + + Future _saveLiveQuote(Map json) async { + final prefs = await SharedPreferences.getInstance(); + final saved = [jsonEncode(json), ...?prefs.getStringList(_liveQuotesKey)]; + await prefs.setStringList(_liveQuotesKey, saved.take(_maxLiveQuotes).toList()); + } + + Future> _quoteJson({ + required SwapToken from, + required SwapToken to, + required BigInt amount, + required String refundAddress, + required String recipient, + required int slippageBps, + required bool dry, }) async { final json = await _send( 'POST', @@ -111,6 +169,7 @@ class SwapService { 'slippageTolerance': slippageBps, 'originAsset': from.assetId, 'depositType': 'ORIGIN_CHAIN', + 'depositMode': _memoNetworks.contains(from.network) ? 'MEMO' : 'SIMPLE', 'destinationAsset': to.assetId, 'amount': amount.toString(), 'refundTo': refundAddress, @@ -119,24 +178,10 @@ class SwapService { 'recipientType': 'DESTINATION_CHAIN', 'deadline': DateTime.now().toUtc().add(depositWindowFor(from.network)).toIso8601String(), 'quoteWaitingTimeMs': quoteWaitingTime.inMilliseconds, + 'referral': AppConstants.oneClickReferral, }, ); - return SwapQuote.fromJson(json as Map, fromToken: from, toToken: to); - } - - /// Re-quotes [quote] live so 1Click reserves a deposit address for it. - Future createSwap(SwapQuote quote) async { - final live = await getQuote( - from: quote.fromToken, - to: quote.toToken, - amount: quote.amountIn, - refundAddress: quote.refundAddress, - recipient: quote.recipient, - slippageBps: quote.slippageBps, - dry: false, - ); - if (live.depositAddress == null) throw StateError('Live quote ${live.correlationId} has no deposit address'); - return SwapOrder(quote: live, status: SwapStatus.pendingDeposit); + return json as Map; } /// Tells 1Click which transaction paid [order]'s deposit address, so it diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index 73ea6567a..c23891b12 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -70,6 +70,8 @@ Future _quote(SwapService service, {bool dry = true}) => service.getQ ); void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + group('getQuote', () { test('posts the 1Click quote request and parses the quote', () async { late Map sent; @@ -92,6 +94,7 @@ void main() { 'slippageTolerance': 100, 'originAsset': _usdcEth.assetId, 'depositType': 'ORIGIN_CHAIN', + 'depositMode': 'SIMPLE', 'destinationAsset': _wnear.assetId, 'amount': '10000000', 'refundTo': _refund, @@ -100,6 +103,7 @@ void main() { 'recipientType': 'DESTINATION_CHAIN', 'deadline': isA(), 'quoteWaitingTimeMs': 3000, + 'referral': 'quantus', }); final deadline = DateTime.parse(sent['deadline'] as String); expect(deadline.difference(before), greaterThanOrEqualTo(SwapService.depositWindow)); @@ -118,9 +122,27 @@ void main() { expect(quote.deadline, DateTime.utc(2026, 9, 20, 6, 0, 50)); expect(quote.timeEstimate, const Duration(seconds: 45)); expect(quote.correlationId, 'corr-1'); + expect(quote.signature, 'ed25519:sig'); expect(quote.depositAddress, isNull); }); + test('asks for a memo deposit on chains that need one', () async { + late Map sent; + final service = _service((r) async { + sent = jsonDecode(r.body) as Map; + return http.Response(jsonEncode(_quoteResponse()), 200); + }); + const xlm = SwapToken(assetId: 'nep245:xlm', symbol: 'XLM', network: 'STELLAR', decimals: 7, usdPrice: 0.2); + await service.getQuote( + from: xlm, + to: _wnear, + amount: BigInt.one, + refundAddress: 'GREFUND', + recipient: _recipient, + ); + expect(sent['depositMode'], 'MEMO'); + }); + test('gives a deposit from a slow chain two hours', () async { late Map sent; final before = DateTime.now().toUtc(); @@ -198,6 +220,11 @@ void main() { expect(order.status, SwapStatus.pendingDeposit); expect(order.depositAddress, '0xdeposit'); expect(order.quote.deadline, DateTime.utc(2026, 9, 20, 6, 10, 50)); + + final saved = await service.getSavedLiveQuotes(); + expect(saved, hasLength(1)); + expect(saved.single['signature'], 'ed25519:sig'); + expect((saved.single['quote'] as Map)['depositAddress'], '0xdeposit'); expect(order.amountOut, isNull); }); From fd45f10d072eaceb985c3114ada8abb6aa6983c2 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 10:36:21 +0800 Subject: [PATCH 10/11] Verify 1Click quote signatures and the echoed request Every quote response now has to carry 1Click's Ed25519 signature over the request and quote fields, checked the way verifyQuoteSignature in @defuse-protocol/one-click-sdk-typescript does it: key-sorted JSON of the signed fields plus the timestamp, SHA-256, base58, then the signature over the bytes of that string against the 1Click manager key. The echoed quoteRequest must also repeat the amount, assets, addresses, slippage and dry flag that were sent. Either failing throws SwapQuoteIntegrityException before the quote is shown or a deposit address is used. The SDK's staging fixtures verify byte for byte against its staging key. Test fakes sign their responses with a generated key. Adds crypto, ed25519_edwards and base_x as direct SDK dependencies; all three were already in the lock file through polkadart. --- mobile-app/docs/near-intents-swaps.md | 4 +- mobile-app/pubspec.lock | 2 +- mobile-app/pubspec.yaml | 1 + mobile-app/test/screens/swap_flow_test.dart | 30 ++- quantus_sdk/lib/quantus_sdk.dart | 1 + .../lib/src/constants/app_constants.dart | 3 + .../services/one_click_quote_signature.dart | 109 +++++++++ .../lib/src/services/swap_service.dart | 93 +++++-- quantus_sdk/pubspec.lock | 6 +- quantus_sdk/pubspec.yaml | 3 + .../test/services/swap_service_test.dart | 231 ++++++++++++++---- 11 files changed, 403 insertions(+), 80 deletions(-) create mode 100644 quantus_sdk/lib/src/services/one_click_quote_signature.dart diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md index 5c64bdcda..090e7e14d 100644 --- a/mobile-app/docs/near-intents-swaps.md +++ b/mobile-app/docs/near-intents-swaps.md @@ -116,7 +116,7 @@ The app never blocks on anything but its own HTTP calls and, swapping out, the Q | Call | App sends | App reads back | | --- | --- | --- | | `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. The entry whose `assetId` is `AppConstants.quantusIntentsAssetId` is QTC as 1Click lists it and takes over from the app's own QTC metadata; it is refused if its decimals differ from the chain's. | -| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `depositType: ORIGIN_CHAIN`, `depositMode` (`MEMO` for a Stellar origin, which 1Click refuses to quote otherwise, `SIMPLE` everywhere else), `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`, `referral: quantus`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`, `signature`. The signed response of every live quote is kept on the device (`SwapService.getSavedLiveQuotes`, last 50): 1Click settles a dispute about a deposit address from it. The request is echoed back in `quoteRequest`. | +| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `depositType: ORIGIN_CHAIN`, `depositMode` (`MEMO` for a Stellar origin, which 1Click refuses to quote otherwise, `SIMPLE` everywhere else), `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`, `referral: quantus`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`, `signature`. Every quote's signature is checked before it is used (`OneClickQuoteSignature`, a port of `verifyQuoteSignature` from the 1Click TypeScript SDK: Ed25519 over the base58 SHA-256 of a key-sorted JSON of the signed request and quote fields plus `timestamp`, against `AppConstants.oneClickManagerPublicKey`), and the echoed `quoteRequest` must repeat the amount, assets, addresses, slippage and `dry` flag that were sent. Either failing is a `SwapQuoteIntegrityException`. The signed response of every live quote is kept on the device (`SwapService.getSavedLiveQuotes`, last 50): 1Click settles a dispute about a deposit address from it. | | `POST /v0/deposit/submit` | `txHash` of the QTC transfer, `depositAddress`, `memo` if any. Swapping out only. | nothing the app uses | | `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `originChainTxHashes[].hash`, `destinationChainTxHashes[].hash`. | @@ -170,7 +170,7 @@ stateDiagram-v2 | `SwapToken`, `SwapQuote`, `SwapOrder` | `quantus_sdk/lib/src/models/swap_*.dart` | Asset ids and BigInt base-unit amounts. | | Endpoints | `quantus_sdk/lib/src/constants/app_constants.dart` | `oneClickEndpoint`, `quantusIntentsAssetId`. | -Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request shape, response parsing, every status, deposit submission and error handling against a mock HTTP client. `mobile-app/test/screens/swap_flow_test.dart` drives the form, the swap-out confirmation, the price-moved re-confirmation and the outcome screens. +Tests: `quantus_sdk/test/services/swap_service_test.dart` covers the request shape, response parsing, every status, deposit submission, error handling, and signature checks against a mock HTTP client that signs its responses; the 1Click SDK's staging fixtures are verified byte for byte against its staging key. `mobile-app/test/screens/swap_flow_test.dart` drives the form, the swap-out confirmation, the price-moved re-confirmation and the outcome screens. ## Blockers diff --git a/mobile-app/pubspec.lock b/mobile-app/pubspec.lock index 958342931..e6c301ee5 100644 --- a/mobile-app/pubspec.lock +++ b/mobile-app/pubspec.lock @@ -346,7 +346,7 @@ packages: source: hosted version: "2.1.0" ed25519_edwards: - dependency: transitive + dependency: "direct dev" description: name: ed25519_edwards sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index 70440012f..de238bfd0 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: async: ^2.13.1 dev_dependencies: + ed25519_edwards: ^0.3.1 flutter_test: sdk: flutter integration_test: diff --git a/mobile-app/test/screens/swap_flow_test.dart b/mobile-app/test/screens/swap_flow_test.dart index 4d4c90850..b8055c565 100644 --- a/mobile-app/test/screens/swap_flow_test.dart +++ b/mobile-app/test/screens/swap_flow_test.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:typed_data'; +import 'package:ed25519_edwards/ed25519_edwards.dart' as ed25519; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/misc.dart'; @@ -27,6 +29,8 @@ const _usdc = SwapToken(assetId: 'nep141:usdc.omft.near', symbol: 'USDC', networ const _external = '0xa5f3c2b1d4e8a7c9f2b5e6d7c8b9a0f1e2d3c7b9'; final _deposit = 'qzdeposit${'y' * 40}'; final _qtc = SwapService.quantusToken(usdPrice: 0.1); +final _managerKeyPair = ed25519.generateKey(); +final _managerPublicKey = OneClickQuoteSignature.encodeKey(Uint8List.fromList(_managerKeyPair.publicKey.bytes)); final _unit = BigInt.from(10).pow(AppConstants.decimals); class _FakeSubmission extends Fake implements TransactionSubmissionService { @@ -57,7 +61,8 @@ class _OneClick { Iterable get liveQuotes => requests.where((r) => r.url.path == '/v0/quote' && (jsonDecode(r.body) as Map)['dry'] == false); - SwapService service() => SwapService(endpoint: 'https://oneclick.test', client: MockClient(_handle)); + SwapService service() => + SwapService(endpoint: 'https://oneclick.test', client: MockClient(_handle), managerPublicKey: _managerPublicKey); Future _handle(http.Request request) async { requests.add(request); @@ -78,7 +83,7 @@ class _OneClick { Map _quoteJson(Map request) { final dry = request['dry'] as bool; final out = dry ? dryOut : liveOut; - return { + final response = { 'quote': { if (!dry) 'depositAddress': _deposit, 'amountIn': request['amount'], @@ -90,15 +95,26 @@ class _OneClick { 'timeEstimate': 60, }, 'quoteRequest': { - 'slippageTolerance': request['slippageTolerance'], - 'refundTo': request['refundTo'], - 'recipient': request['recipient'], - 'deadline': request['deadline'], + for (final k in const [ + 'dry', + 'originAsset', + 'destinationAsset', + 'amount', + 'slippageTolerance', + 'refundTo', + 'recipient', + 'deadline', + ]) + k: request[k], }, 'correlationId': dry ? 'dry' : 'live', - 'signature': 'ed25519:sig', 'timestamp': DateTime.now().toUtc().toIso8601String(), }; + final message = utf8.encode(OneClickQuoteSignature.hash(response)); + return { + ...response, + 'signature': OneClickQuoteSignature.encodeKey(ed25519.sign(_managerKeyPair.privateKey, message)), + }; } } diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index 50f9f70fb..2765e314c 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -78,6 +78,7 @@ export 'src/services/settings_service.dart'; export 'src/services/substrate_service.dart'; export 'src/services/transaction_fee.dart'; export 'src/services/swap_service.dart'; +export 'src/services/one_click_quote_signature.dart'; export 'src/models/swap_token.dart'; export 'src/models/swap_quote.dart'; export 'src/models/swap_order.dart'; diff --git a/quantus_sdk/lib/src/constants/app_constants.dart b/quantus_sdk/lib/src/constants/app_constants.dart index 0f2cd6c9b..c8b238b5c 100644 --- a/quantus_sdk/lib/src/constants/app_constants.dart +++ b/quantus_sdk/lib/src/constants/app_constants.dart @@ -42,6 +42,9 @@ class AppConstants { // Distribution channel id 1Click records on every quote. static const String oneClickReferral = 'quantus'; + // Key 1Click signs quotes with; from @defuse-protocol/one-click-sdk-typescript. + static const String oneClickManagerPublicKey = 'ed25519:reYaWhvwu8Jzo3WUM3zhn6VrhuMEF4eADL17qtRVifc'; + // Quantus is not listed on NEAR Intents yet, so every quote into it is // rejected as "tokenOut is not valid". Replace with the listed asset id. static const String quantusIntentsAssetId = 'nep141:qtc.omft.near'; diff --git a/quantus_sdk/lib/src/services/one_click_quote_signature.dart b/quantus_sdk/lib/src/services/one_click_quote_signature.dart new file mode 100644 index 000000000..1f88ddaa4 --- /dev/null +++ b/quantus_sdk/lib/src/services/one_click_quote_signature.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:base_x/base_x.dart'; +import 'package:crypto/crypto.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart' as ed25519; +import 'package:quantus_sdk/src/constants/app_constants.dart'; + +/// 1Click signs every quote response: Ed25519 over the base58 SHA-256 of a +/// key-sorted JSON of chosen request and quote fields plus the timestamp. +/// Mirrors verifyQuoteSignature in @defuse-protocol/one-click-sdk-typescript, +/// so a tampered deposit address or amount fails to verify. +class OneClickQuoteSignature { + static const _prefix = 'ed25519:'; + static final _base58 = BaseXCodec('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'); + static const _requestFields = [ + 'dry', + 'swapType', + 'slippageTolerance', + 'originAsset', + 'depositType', + 'destinationAsset', + 'amount', + 'refundTo', + 'refundType', + 'recipient', + 'recipientType', + 'deadline', + ]; + static const _optionalRequestFields = [ + 'quoteWaitingTimeMs', + 'referral', + 'virtualChainRecipient', + 'virtualChainRefundRecipient', + 'customRecipientMsg', + ]; + static const _quoteFields = [ + 'amountIn', + 'amountInFormatted', + 'amountInUsd', + 'minAmountIn', + 'amountOut', + 'amountOutFormatted', + 'amountOutUsd', + 'minAmountOut', + ]; + static const _liveQuoteFields = [ + 'depositAddress', + 'depositMemo', + 'deadline', + 'timeWhenInactive', + 'timeEstimate', + 'refundFee', + 'withdrawFee', + ]; + + /// Whether [response] carries a valid signature from [managerPublicKey]. + static bool verify(Map response, {String managerPublicKey = AppConstants.oneClickManagerPublicKey}) { + final signature = response['signature']; + if (signature is! String) return false; + try { + final message = utf8.encode(hash(response)); + return ed25519.verify(ed25519.PublicKey(decodeKey(managerPublicKey)), message, decodeKey(signature)); + } on FormatException { + return false; + } on ArgumentError { + return false; + } + } + + /// Base58 SHA-256 of the signed payload; the bytes of this string are what + /// the signature covers. + static String hash(Map response) { + final request = response['quoteRequest'] as Map; + final quote = response['quote'] as Map; + final signed = { + for (final k in _requestFields) + if (request[k] != null) k: request[k], + for (final k in _optionalRequestFields) + if (_truthy(request[k])) k: request[k], + for (final k in _quoteFields) + if (quote[k] != null) k: quote[k], + if (request['dry'] != true) + for (final k in _liveQuoteFields) + if (_truthy(quote[k])) k: quote[k], + if (response['timestamp'] != null) 'timestamp': response['timestamp'], + }; + final digest = sha256.convert(utf8.encode(stableJson(signed))); + return _base58.encode(Uint8List.fromList(digest.bytes)); + } + + /// JSON with keys sorted at every level and no whitespace, as + /// json-stable-stringify prints it; a whole-number double prints as an + /// integer the way JavaScript does. + static String stableJson(Object? value) => switch (value) { + Map() => + '{${(value.keys.cast().toList()..sort()).map((k) => '${jsonEncode(k)}:${stableJson(value[k])}').join(',')}}', + List() => '[${value.map(stableJson).join(',')}]', + double() when value.isFinite && value == value.truncateToDouble() && value.abs() < 1e21 => value.toInt().toString(), + _ => jsonEncode(value), + }; + + static Uint8List decodeKey(String value) => + _base58.decode(value.startsWith(_prefix) ? value.substring(_prefix.length) : value); + + static String encodeKey(Uint8List bytes) => '$_prefix${_base58.encode(bytes)}'; + + static bool _truthy(Object? v) => v != null && v != false && v != 0 && v != '' && !(v is double && v.isNaN); +} diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index 6ae397bd4..a16f6b9f2 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -5,6 +5,7 @@ import 'package:quantus_sdk/src/constants/app_constants.dart'; import 'package:quantus_sdk/src/models/swap_order.dart'; import 'package:quantus_sdk/src/models/swap_quote.dart'; import 'package:quantus_sdk/src/models/swap_token.dart'; +import 'package:quantus_sdk/src/services/one_click_quote_signature.dart'; import 'package:quantus_sdk/src/utils/print.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,6 +20,18 @@ class SwapApiException implements Exception { String toString() => 'SwapApiException($statusCode): $message'; } +/// A quote response whose signature is not 1Click's, or which answers a +/// different request than the one sent. +class SwapQuoteIntegrityException implements Exception { + final String message; + final String? correlationId; + + const SwapQuoteIntegrityException(this.message, {this.correlationId}); + + @override + String toString() => 'SwapQuoteIntegrityException($correlationId): $message'; +} + /// Client for the NEAR Intents 1Click API. A quote names a deposit address on /// the origin chain; whatever the user sends there before the deadline is /// swapped by solvers and paid out to the recipient, or refunded. Nothing in @@ -37,6 +50,18 @@ class SwapService { static const _memoNetworks = {'STELLAR'}; static const _liveQuotesKey = 'swap_live_quotes'; static const _maxLiveQuotes = 50; + + /// Request fields 1Click echoes back that must match what was sent, so a + /// signed quote is a quote for this swap and not another. + static const _echoedFields = [ + 'dry', + 'originAsset', + 'destinationAsset', + 'amount', + 'refundTo', + 'recipient', + 'slippageTolerance', + ]; static const quoteWaitingTime = Duration(seconds: 3); static const statusPollInterval = Duration(seconds: 5); static const _savedAddressesKey = 'swap_saved_addresses'; @@ -48,14 +73,20 @@ class SwapService { final http.Client _client; final Uri _base; final String? _apiKey; + final String _managerPublicKey; List? _cachedFromTokens; DateTime? _cachedFromTokensAt; SwapToken? _listedQuantus; - SwapService({http.Client? client, String endpoint = AppConstants.oneClickEndpoint, String? apiKey}) - : _client = client ?? http.Client(), - _base = Uri.parse(endpoint), - _apiKey = apiKey; + SwapService({ + http.Client? client, + String endpoint = AppConstants.oneClickEndpoint, + String? apiKey, + String managerPublicKey = AppConstants.oneClickManagerPublicKey, + }) : _client = client ?? http.Client(), + _base = Uri.parse(endpoint), + _apiKey = apiKey, + _managerPublicKey = managerPublicKey; /// How long a deposit on [network] has before its quote expires. static Duration depositWindowFor(String network) => @@ -160,28 +191,38 @@ class SwapService { required int slippageBps, required bool dry, }) async { - final json = await _send( - 'POST', - '/v0/quote', - body: { - 'dry': dry, - 'swapType': 'EXACT_INPUT', - 'slippageTolerance': slippageBps, - 'originAsset': from.assetId, - 'depositType': 'ORIGIN_CHAIN', - 'depositMode': _memoNetworks.contains(from.network) ? 'MEMO' : 'SIMPLE', - 'destinationAsset': to.assetId, - 'amount': amount.toString(), - 'refundTo': refundAddress, - 'refundType': 'ORIGIN_CHAIN', - 'recipient': recipient, - 'recipientType': 'DESTINATION_CHAIN', - 'deadline': DateTime.now().toUtc().add(depositWindowFor(from.network)).toIso8601String(), - 'quoteWaitingTimeMs': quoteWaitingTime.inMilliseconds, - 'referral': AppConstants.oneClickReferral, - }, - ); - return json as Map; + final body = { + 'dry': dry, + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': slippageBps, + 'originAsset': from.assetId, + 'depositType': 'ORIGIN_CHAIN', + 'depositMode': _memoNetworks.contains(from.network) ? 'MEMO' : 'SIMPLE', + 'destinationAsset': to.assetId, + 'amount': amount.toString(), + 'refundTo': refundAddress, + 'refundType': 'ORIGIN_CHAIN', + 'recipient': recipient, + 'recipientType': 'DESTINATION_CHAIN', + 'deadline': DateTime.now().toUtc().add(depositWindowFor(from.network)).toIso8601String(), + 'quoteWaitingTimeMs': quoteWaitingTime.inMilliseconds, + 'referral': AppConstants.oneClickReferral, + }; + final json = await _send('POST', '/v0/quote', body: body) as Map; + final correlationId = json['correlationId'] as String?; + if (!OneClickQuoteSignature.verify(json, managerPublicKey: _managerPublicKey)) { + throw SwapQuoteIntegrityException('Quote signature is not from 1Click', correlationId: correlationId); + } + final echoed = json['quoteRequest'] as Map; + for (final field in _echoedFields) { + if (echoed[field] != body[field]) { + throw SwapQuoteIntegrityException( + 'Quote answers a different request: $field is ${echoed[field]}, sent ${body[field]}', + correlationId: correlationId, + ); + } + } + return json; } /// Tells 1Click which transaction paid [order]'s deposit address, so it diff --git a/quantus_sdk/pubspec.lock b/quantus_sdk/pubspec.lock index 112539b80..bf90ac270 100644 --- a/quantus_sdk/pubspec.lock +++ b/quantus_sdk/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "2.13.1" base_x: - dependency: transitive + dependency: "direct main" description: name: base_x sha256: "519abcdafd637d4b6bd7e72fabd8f9264935f804b9b9f6c5d8411c7d52cbf8fd" @@ -162,7 +162,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -202,7 +202,7 @@ packages: source: hosted version: "3.2.4" ed25519_edwards: - dependency: transitive + dependency: "direct main" description: name: ed25519_edwards sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" diff --git a/quantus_sdk/pubspec.yaml b/quantus_sdk/pubspec.yaml index 2cd9ef23a..2945a0029 100644 --- a/quantus_sdk/pubspec.yaml +++ b/quantus_sdk/pubspec.yaml @@ -41,6 +41,9 @@ dependencies: path: ^1.9.1 flash: ^3.1.1 collection: ^1.19.1 + crypto: ^3.0.7 + ed25519_edwards: ^0.3.1 + base_x: ^2.0.1 # SDK-only ss58: diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index c23891b12..9d4bf5fb2 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:typed_data'; +import 'package:ed25519_edwards/ed25519_edwards.dart' as ed25519; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -17,37 +19,51 @@ const _wnear = SwapToken(assetId: 'nep141:wrap.near', symbol: 'WNEAR', network: const _refund = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; const _recipient = 'qznQKhufTDfU3szAzfgCny7wMhxUN3qjEqneiRUNgC7MjSDyG'; -Map _quoteResponse({bool dry = true, String? depositAddress}) => { - 'quote': { - 'depositAddress': ?depositAddress, - 'amountIn': '10000000', - 'amountInFormatted': '10.0', - 'amountInUsd': '9.996620000000', - 'minAmountIn': '10000000', - 'amountOut': '2861051688077884367566500', - 'amountOutFormatted': '2.8610516880778843675665', - 'amountOutUsd': '9.956459874511', - 'minAmountOut': '2832441171197105523890835', - if (!dry) 'deadline': '2026-09-20T06:10:50.000Z', - 'timeEstimate': 45, - 'refundFee': '300000', - 'withdrawFee': '0', - }, - 'quoteRequest': { - 'dry': dry, - 'swapType': 'EXACT_INPUT', - 'slippageTolerance': 100, - 'originAsset': _usdcEth.assetId, - 'destinationAsset': _wnear.assetId, - 'amount': '10000000', - 'refundTo': _refund, - 'recipient': _recipient, - 'deadline': '2026-09-20T06:00:50.000Z', - }, - 'signature': 'ed25519:sig', - 'timestamp': '2026-09-20T05:50:50.975Z', - 'correlationId': 'corr-1', -}; +/// Stands in for 1Click's signing key; responses the mock serves are signed with it. +final _managerKeyPair = ed25519.generateKey(); +final _managerPublicKey = OneClickQuoteSignature.encodeKey(Uint8List.fromList(_managerKeyPair.publicKey.bytes)); + +Map _signed(Map response) { + final message = utf8.encode(OneClickQuoteSignature.hash(response)); + return { + ...response, + 'signature': OneClickQuoteSignature.encodeKey(ed25519.sign(_managerKeyPair.privateKey, message)), + }; +} + +/// A quote answering the fixture request, or [request] when given. +Map _quoteResponse({bool dry = true, String? depositAddress, Map? request}) => + _signed({ + 'quote': { + 'depositAddress': ?depositAddress, + 'amountIn': '10000000', + 'amountInFormatted': '10.0', + 'amountInUsd': '9.996620000000', + 'minAmountIn': '10000000', + 'amountOut': '2861051688077884367566500', + 'amountOutFormatted': '2.8610516880778843675665', + 'amountOutUsd': '9.956459874511', + 'minAmountOut': '2832441171197105523890835', + if (!dry) 'deadline': '2026-09-20T06:10:50.000Z', + 'timeEstimate': 45, + 'refundFee': '300000', + 'withdrawFee': '0', + }, + 'quoteRequest': { + 'dry': dry, + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': 100, + 'originAsset': _usdcEth.assetId, + 'destinationAsset': _wnear.assetId, + 'amount': '10000000', + 'refundTo': _refund, + 'recipient': _recipient, + 'deadline': '2026-09-20T06:00:50.000Z', + ...?request, + }, + 'timestamp': '2026-09-20T05:50:50.975Z', + 'correlationId': 'corr-1', + }); Map _statusResponse(String status, {Map? details}) => { 'correlationId': 'corr-2', @@ -57,8 +73,14 @@ Map _statusResponse(String status, {Map? detai 'swapDetails': ?details, }; -SwapService _service(Future Function(http.Request request) handler, {String? apiKey}) => - SwapService(endpoint: 'https://oneclick.test', apiKey: apiKey, client: MockClient(handler)); +SwapService _service(Future Function(http.Request request) handler, {String? apiKey}) => SwapService( + endpoint: 'https://oneclick.test', + apiKey: apiKey, + client: MockClient(handler), + managerPublicKey: _managerPublicKey, +); + +Map _sentRequest(http.Request r) => jsonDecode(r.body) as Map; Future _quote(SwapService service, {bool dry = true}) => service.getQuote( from: _usdcEth, @@ -122,15 +144,15 @@ void main() { expect(quote.deadline, DateTime.utc(2026, 9, 20, 6, 0, 50)); expect(quote.timeEstimate, const Duration(seconds: 45)); expect(quote.correlationId, 'corr-1'); - expect(quote.signature, 'ed25519:sig'); + expect(quote.signature, startsWith('ed25519:')); expect(quote.depositAddress, isNull); }); test('asks for a memo deposit on chains that need one', () async { late Map sent; final service = _service((r) async { - sent = jsonDecode(r.body) as Map; - return http.Response(jsonEncode(_quoteResponse()), 200); + sent = _sentRequest(r); + return http.Response(jsonEncode(_quoteResponse(request: sent)), 200); }); const xlm = SwapToken(assetId: 'nep245:xlm', symbol: 'XLM', network: 'STELLAR', decimals: 7, usdPrice: 0.2); await service.getQuote( @@ -147,8 +169,8 @@ void main() { late Map sent; final before = DateTime.now().toUtc(); final service = _service((r) async { - sent = jsonDecode(r.body) as Map; - return http.Response(jsonEncode(_quoteResponse()), 200); + sent = _sentRequest(r); + return http.Response(jsonEncode(_quoteResponse(request: sent)), 200); }); const btc = SwapToken(assetId: 'nep141:btc.omft.near', symbol: 'BTC', network: 'BTC', decimals: 8, usdPrice: 1); await service.getQuote(from: btc, to: _wnear, amount: BigInt.one, refundAddress: _refund, recipient: _recipient); @@ -158,6 +180,26 @@ void main() { expect(deadline.difference(before), lessThan(const Duration(hours: 2, seconds: 10))); }); + test('rejects a quote whose signature does not cover its deposit address', () async { + final service = _service((_) async { + final tampered = _quoteResponse(dry: false, depositAddress: '0xdeposit'); + (tampered['quote'] as Map)['depositAddress'] = '0xattacker'; + return http.Response(jsonEncode(tampered), 200); + }); + await expectLater( + _quote(service, dry: false), + throwsA(isA().having((e) => e.message, 'message', contains('signature'))), + ); + }); + + test('rejects a signed quote that answers a different request', () async { + final service = _service((_) async => http.Response(jsonEncode(_quoteResponse(request: {'amount': '999'})), 200)); + await expectLater( + _quote(service), + throwsA(isA().having((e) => e.message, 'message', contains('amount'))), + ); + }); + test('sends no API key header when none is configured', () async { late http.Request request; final service = _service((r) async { @@ -223,13 +265,13 @@ void main() { final saved = await service.getSavedLiveQuotes(); expect(saved, hasLength(1)); - expect(saved.single['signature'], 'ed25519:sig'); + expect(saved.single['signature'], startsWith('ed25519:')); expect((saved.single['quote'] as Map)['depositAddress'], '0xdeposit'); expect(order.amountOut, isNull); }); test('refuses a live quote without a deposit address', () async { - final service = _service((_) async => http.Response(jsonEncode(_quoteResponse(dry: false)), 200)); + final service = _service((r) async => http.Response(jsonEncode(_quoteResponse(request: _sentRequest(r))), 200)); await expectLater(service.createSwap(await _quote(service)), throwsA(isA())); }); }); @@ -239,7 +281,8 @@ void main() { setUp(() async { final service = _service( - (_) async => http.Response(jsonEncode(_quoteResponse(dry: false, depositAddress: '0xdeposit')), 200), + (r) async => + http.Response(jsonEncode(_quoteResponse(depositAddress: '0xdeposit', request: _sentRequest(r))), 200), ); order = await service.createSwap(await _quote(service)); }); @@ -351,6 +394,112 @@ void main() { }); }); + group('OneClickQuoteSignature', () { + const stagingKey = 'ed25519:5J5tkaxyPoR3Q9S8LXfo5bWnXK5Z2bctJ4mB9gENh7co'; + final stagingRequest = { + 'depositMode': 'SIMPLE', + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': 100, + 'originAsset': '1cs_v1:btc:native:coin', + 'depositType': 'ORIGIN_CHAIN', + 'destinationAsset': 'nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.stft.near', + 'amount': '10000', + 'refundTo': 'bc1q6mte80265ghwq4vsrpm9lnaz46uvdreu9z8wly', + 'refundType': 'ORIGIN_CHAIN', + 'recipient': '0xcac3C41676deF4FE375E57118f3eB83A99105577', + 'recipientType': 'DESTINATION_CHAIN', + 'deadline': '2026-06-23T19:00:00.000Z', + 'confidentiality': 'public', + 'quoteWaitingTimeMs': 0, + 'appFees': [ + {'recipient': '5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd', 'fee': 10}, + ], + }; + final stagingLive = { + 'correlationId': 'd4f1b110-46cc-4682-aa3f-44d81ffe4b80', + 'timestamp': '2026-06-23T17:10:41.104Z', + 'signature': 'ed25519:53wcpim7FDNLbBHVezUpakthWq2TR9Lag3PwW3e8Cxmz4bFEodcc4rui5BiVHRRaHocYE9URVapzJD8JxLNDs8K9', + 'quoteRequest': {'dry': false, ...stagingRequest}, + 'quote': { + 'amountIn': '10000', + 'amountInFormatted': '0.0001', + 'amountInUsd': '6.237600000000', + 'minAmountIn': '10000', + 'amountOut': '5931560', + 'amountOutFormatted': '5.93156', + 'amountOutUsd': '5.925171709880', + 'minAmountOut': '5872244', + 'timeEstimate': 812, + 'refundFee': '1900', + 'withdrawFee': '300000', + 'deadline': '2026-06-26T19:00:00.000Z', + 'timeWhenInactive': '2026-06-26T19:00:00.000Z', + 'depositAddress': 'bc1q873cxltdc560dth6tpwqpehq9uvhxxcdgwnmnw', + }, + }; + final stagingDry = { + 'correlationId': '7d6d78f0-601f-4022-9735-854a22ed9dcb', + 'timestamp': '2026-06-23T17:10:55.616Z', + 'signature': 'ed25519:3yVRcYGXRVj2YqrUng4Ne2yiWgh9YQfer46KW6sXiWzoyRHgsifwDp1HSZW7VLRTdKXoMgxJce22LQ9dcoihyfu5', + 'quoteRequest': {'dry': true, ...stagingRequest}, + 'quote': { + 'amountIn': '10000', + 'amountInFormatted': '0.0001', + 'amountInUsd': '6.237600000000', + 'minAmountIn': '10000', + 'amountOut': '5935024', + 'amountOutFormatted': '5.935024', + 'amountOutUsd': '5.928631979152', + 'minAmountOut': '5875673', + 'timeEstimate': 812, + 'refundFee': '1900', + 'withdrawFee': '300000', + }, + }; + + test('verifies the TypeScript SDK staging fixtures, live and dry', () { + expect(OneClickQuoteSignature.verify(stagingLive, managerPublicKey: stagingKey), isTrue); + expect(OneClickQuoteSignature.verify(stagingDry, managerPublicKey: stagingKey), isTrue); + }); + + test('ignores the null routing fields the status endpoint adds', () { + final fromStatus = { + ...stagingLive, + 'quoteRequest': { + ...stagingLive['quoteRequest'] as Map, + 'virtualChainRecipient': null, + 'virtualChainRefundRecipient': null, + 'referral': null, + }, + }; + expect(OneClickQuoteSignature.verify(fromStatus, managerPublicKey: stagingKey), isTrue); + }); + + test('rejects a tampered deposit address, a foreign key and malformed signatures', () { + final tampered = { + ...stagingLive, + 'quote': { + ...stagingLive['quote'] as Map, + 'depositAddress': 'bc1q0000000000000000000000000000000000000000', + }, + }; + expect(OneClickQuoteSignature.verify(tampered, managerPublicKey: stagingKey), isFalse); + expect(OneClickQuoteSignature.verify(stagingLive), isFalse); + final malformed = {...stagingLive, 'signature': 'ed25519:not-base58-0OIl'}; + expect(OneClickQuoteSignature.verify(malformed, managerPublicKey: stagingKey), isFalse); + expect(OneClickQuoteSignature.verify({...stagingLive, 'signature': null}, managerPublicKey: stagingKey), isFalse); + }); + + test('prints JSON the way json-stable-stringify does', () { + final json = OneClickQuoteSignature.stableJson({ + 'b': 1.0, + 'a': ['x', 2.5, null], + 'c': {'z': true, 'y': 'q"'}, + }); + expect(json, '{"a":["x",2.5,null],"b":1,"c":{"y":"q\\"","z":true}}'); + }); + }); + group('SwapStatus', () { test('only success, refunded and failed are final', () { expect(SwapStatus.values.where((s) => s.isFinal), [SwapStatus.success, SwapStatus.refunded, SwapStatus.failed]); From 47e99a10d425e2dced59ad11689623071a5628b1 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 26 Sep 2026 21:46:28 +0800 Subject: [PATCH 11/11] Tie a live quote to the reviewed amount and a usable deposit deadline A quote now has to price exactly the amount sent (quote.amountIn equals the request amount) and echo the deadline that was sent, on top of the signature and the other echoed fields, so a swap out transfers the amount the user reviewed and nothing else. createSwap refuses a live quote whose deposit address has less than five minutes left, so a stale or replayed signed response can no longer direct a deposit to an address that is about to go cold. The review screen reuses the same lead when deciding whether to keep a live order for a retry. The request deadline goes out at millisecond precision so the echo can be compared as an instant. --- mobile-app/docs/near-intents-swaps.md | 2 +- .../v2/screens/swap/review_swap_screen.dart | 4 +- .../lib/src/services/swap_service.dart | 31 +++- .../test/services/swap_service_test.dart | 139 ++++++++++++------ 4 files changed, 129 insertions(+), 47 deletions(-) diff --git a/mobile-app/docs/near-intents-swaps.md b/mobile-app/docs/near-intents-swaps.md index 090e7e14d..9564c5f7e 100644 --- a/mobile-app/docs/near-intents-swaps.md +++ b/mobile-app/docs/near-intents-swaps.md @@ -116,7 +116,7 @@ The app never blocks on anything but its own HTTP calls and, swapping out, the Q | Call | App sends | App reads back | | --- | --- | --- | | `GET /v0/tokens` | nothing | `assetId`, `symbol`, `blockchain`, `decimals`, `price`. One asset per symbol is kept, main network first. The entry whose `assetId` is `AppConstants.quantusIntentsAssetId` is QTC as 1Click lists it and takes over from the app's own QTC metadata; it is refused if its decimals differ from the chain's. | -| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `depositType: ORIGIN_CHAIN`, `depositMode` (`MEMO` for a Stellar origin, which 1Click refuses to quote otherwise, `SIMPLE` everywhere else), `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`, `referral: quantus`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`, `signature`. Every quote's signature is checked before it is used (`OneClickQuoteSignature`, a port of `verifyQuoteSignature` from the 1Click TypeScript SDK: Ed25519 over the base58 SHA-256 of a key-sorted JSON of the signed request and quote fields plus `timestamp`, against `AppConstants.oneClickManagerPublicKey`), and the echoed `quoteRequest` must repeat the amount, assets, addresses, slippage and `dry` flag that were sent. Either failing is a `SwapQuoteIntegrityException`. The signed response of every live quote is kept on the device (`SwapService.getSavedLiveQuotes`, last 50): 1Click settles a dispute about a deposit address from it. | +| `POST /v0/quote` | `dry`, `swapType: EXACT_INPUT`, `slippageTolerance` (the picked 0.5, 1, 2 or 3%, 100 basis points by default), `originAsset`, `destinationAsset`, `amount` in base units, `depositType: ORIGIN_CHAIN`, `depositMode` (`MEMO` for a Stellar origin, which 1Click refuses to quote otherwise, `SIMPLE` everywhere else), `refundTo` and `refundType: ORIGIN_CHAIN`, `recipient` and `recipientType: DESTINATION_CHAIN`, `deadline`, `quoteWaitingTimeMs: 3000`, `referral: quantus`. `X-API-Key` header when a partner key is configured. | `quote.amountIn`, `amountOut`, `minAmountOut`, `amountInUsd`, `amountOutUsd`, `timeEstimate`, `deadline`, `depositAddress` (live only), `depositMemo` (chains such as Stellar need it on the deposit; the deposit screen shows it with its own copy action and a warning), `correlationId`, `signature`. Every quote's signature is checked before it is used (`OneClickQuoteSignature`, a port of `verifyQuoteSignature` from the 1Click TypeScript SDK: Ed25519 over the base58 SHA-256 of a key-sorted JSON of the signed request and quote fields plus `timestamp`, against `AppConstants.oneClickManagerPublicKey`), the echoed `quoteRequest` must repeat the amount, assets, addresses, slippage, `dry` flag and deadline that were sent, and `quote.amountIn` must be the amount sent, so the reviewed input is what a swap out transfers. A live quote whose deposit deadline is under `SwapService.minimumDepositLead` (5 minutes) away is refused before anything is sent. Any of these failing is a `SwapQuoteIntegrityException`. The signed response of every live quote is kept on the device (`SwapService.getSavedLiveQuotes`, last 50): 1Click settles a dispute about a deposit address from it. | | `POST /v0/deposit/submit` | `txHash` of the QTC transfer, `depositAddress`, `memo` if any. Swapping out only. | nothing the app uses | | `GET /v0/status` | `depositAddress`, `depositMemo` if the quote had one | `status`, `swapDetails.amountOut`, `refundedAmount`, `refundReason`, `originChainTxHashes[].hash`, `destinationChainTxHashes[].hash`. | diff --git a/mobile-app/lib/v2/screens/swap/review_swap_screen.dart b/mobile-app/lib/v2/screens/swap/review_swap_screen.dart index b2e2aa7f6..55dbde71e 100644 --- a/mobile-app/lib/v2/screens/swap/review_swap_screen.dart +++ b/mobile-app/lib/v2/screens/swap/review_swap_screen.dart @@ -30,8 +30,6 @@ class ReviewSwapScreen extends ConsumerStatefulWidget { } class _ReviewSwapScreenState extends ConsumerState { - static const _deadlineMargin = Duration(minutes: 2); - late SwapQuote _quote = widget.quote; SwapOrder? _order; bool _confirming = false; @@ -67,7 +65,7 @@ class _ReviewSwapScreenState extends ConsumerState { /// so a retry reuses its deposit address. Future _liveOrder(AppLocalizations l10n) async { final kept = _order; - if (kept != null && kept.quote.deadline.isAfter(DateTime.now().add(_deadlineMargin))) return kept; + if (kept != null && kept.quote.deadline.isAfter(DateTime.now().add(SwapService.minimumDepositLead))) return kept; try { final order = await ref.read(swapServiceProvider).createSwap(_quote); _order = order; diff --git a/quantus_sdk/lib/src/services/swap_service.dart b/quantus_sdk/lib/src/services/swap_service.dart index a16f6b9f2..e45528b83 100644 --- a/quantus_sdk/lib/src/services/swap_service.dart +++ b/quantus_sdk/lib/src/services/swap_service.dart @@ -45,6 +45,10 @@ class SwapService { static const _slowNetworks = {'BTC', 'LTC', 'DOGE', 'BCH', 'DASH', 'ZEC'}; static const _slowDepositWindow = Duration(hours: 2); + /// Least time a live quote's deposit address must stay valid, so a deposit + /// signed now still lands before the address goes cold. + static const minimumDepositLead = Duration(minutes: 5); + /// Chains whose deposits carry a memo; 1Click rejects a plain deposit /// address for them and a memo request for every other chain. static const _memoNetworks = {'STELLAR'}; @@ -166,6 +170,13 @@ class SwapService { ); final live = SwapQuote.fromJson(json, fromToken: quote.fromToken, toToken: quote.toToken); if (live.depositAddress == null) throw StateError('Live quote ${live.correlationId} has no deposit address'); + final lead = live.deadline.difference(DateTime.now()); + if (lead < minimumDepositLead) { + throw SwapQuoteIntegrityException( + 'Deposit deadline ${live.deadline.toIso8601String()} leaves ${lead.inSeconds}s, need $minimumDepositLead', + correlationId: live.correlationId, + ); + } await _saveLiveQuote(json); return SwapOrder(quote: live, status: SwapStatus.pendingDeposit); } @@ -191,6 +202,11 @@ class SwapService { required int slippageBps, required bool dry, }) async { + final now = DateTime.now().toUtc(); + final deadline = DateTime.fromMillisecondsSinceEpoch( + now.add(depositWindowFor(from.network)).millisecondsSinceEpoch, + isUtc: true, + ); final body = { 'dry': dry, 'swapType': 'EXACT_INPUT', @@ -204,7 +220,7 @@ class SwapService { 'refundType': 'ORIGIN_CHAIN', 'recipient': recipient, 'recipientType': 'DESTINATION_CHAIN', - 'deadline': DateTime.now().toUtc().add(depositWindowFor(from.network)).toIso8601String(), + 'deadline': deadline.toIso8601String(), 'quoteWaitingTimeMs': quoteWaitingTime.inMilliseconds, 'referral': AppConstants.oneClickReferral, }; @@ -222,6 +238,19 @@ class SwapService { ); } } + if (!DateTime.parse(echoed['deadline'] as String).isAtSameMomentAs(deadline)) { + throw SwapQuoteIntegrityException( + 'Quote answers a different request: deadline is ${echoed['deadline']}, sent ${body['deadline']}', + correlationId: correlationId, + ); + } + final amountIn = (json['quote'] as Map)['amountIn']; + if (amountIn != body['amount']) { + throw SwapQuoteIntegrityException( + 'Quote prices $amountIn of the origin asset, sent ${body['amount']}', + correlationId: correlationId, + ); + } return json; } diff --git a/quantus_sdk/test/services/swap_service_test.dart b/quantus_sdk/test/services/swap_service_test.dart index 9d4bf5fb2..5d6bd7c88 100644 --- a/quantus_sdk/test/services/swap_service_test.dart +++ b/quantus_sdk/test/services/swap_service_test.dart @@ -31,39 +31,45 @@ Map _signed(Map response) { }; } +final _liveDeadline = DateTime.now().toUtc().add(const Duration(minutes: 30)).toIso8601String(); + /// A quote answering the fixture request, or [request] when given. -Map _quoteResponse({bool dry = true, String? depositAddress, Map? request}) => - _signed({ - 'quote': { - 'depositAddress': ?depositAddress, - 'amountIn': '10000000', - 'amountInFormatted': '10.0', - 'amountInUsd': '9.996620000000', - 'minAmountIn': '10000000', - 'amountOut': '2861051688077884367566500', - 'amountOutFormatted': '2.8610516880778843675665', - 'amountOutUsd': '9.956459874511', - 'minAmountOut': '2832441171197105523890835', - if (!dry) 'deadline': '2026-09-20T06:10:50.000Z', - 'timeEstimate': 45, - 'refundFee': '300000', - 'withdrawFee': '0', - }, - 'quoteRequest': { - 'dry': dry, - 'swapType': 'EXACT_INPUT', - 'slippageTolerance': 100, - 'originAsset': _usdcEth.assetId, - 'destinationAsset': _wnear.assetId, - 'amount': '10000000', - 'refundTo': _refund, - 'recipient': _recipient, - 'deadline': '2026-09-20T06:00:50.000Z', - ...?request, - }, - 'timestamp': '2026-09-20T05:50:50.975Z', - 'correlationId': 'corr-1', - }); +Map _quoteResponse({ + bool dry = true, + String? depositAddress, + Map? request, + String? liveDeadline, +}) => _signed({ + 'quote': { + 'depositAddress': ?depositAddress, + 'amountIn': request?['amount'] ?? '10000000', + 'amountInFormatted': '10.0', + 'amountInUsd': '9.996620000000', + 'minAmountIn': request?['amount'] ?? '10000000', + 'amountOut': '2861051688077884367566500', + 'amountOutFormatted': '2.8610516880778843675665', + 'amountOutUsd': '9.956459874511', + 'minAmountOut': '2832441171197105523890835', + if (!dry) 'deadline': liveDeadline ?? _liveDeadline, + 'timeEstimate': 45, + 'refundFee': '300000', + 'withdrawFee': '0', + }, + 'quoteRequest': { + 'dry': dry, + 'swapType': 'EXACT_INPUT', + 'slippageTolerance': 100, + 'originAsset': _usdcEth.assetId, + 'destinationAsset': _wnear.assetId, + 'amount': '10000000', + 'refundTo': _refund, + 'recipient': _recipient, + 'deadline': '2026-09-20T06:00:50.000Z', + ...?request, + }, + 'timestamp': '2026-09-20T05:50:50.975Z', + 'correlationId': 'corr-1', +}); Map _statusResponse(String status, {Map? details}) => { 'correlationId': 'corr-2', @@ -102,7 +108,7 @@ void main() { final service = _service((r) async { request = r; sent = jsonDecode(r.body) as Map; - return http.Response(jsonEncode(_quoteResponse()), 200); + return http.Response(jsonEncode(_quoteResponse(request: _sentRequest(r))), 200); }, apiKey: 'jwt-token'); final quote = await _quote(service); @@ -128,7 +134,8 @@ void main() { 'referral': 'quantus', }); final deadline = DateTime.parse(sent['deadline'] as String); - expect(deadline.difference(before), greaterThanOrEqualTo(SwapService.depositWindow)); + // The request deadline is sent at millisecond precision. + expect(deadline.difference(before), greaterThan(SwapService.depositWindow - const Duration(seconds: 1))); expect(deadline.difference(before), lessThan(SwapService.depositWindow + const Duration(seconds: 10))); expect(quote.fromToken, _usdcEth); @@ -141,7 +148,7 @@ void main() { expect(quote.slippageBps, 100); expect(quote.refundAddress, _refund); expect(quote.recipient, _recipient); - expect(quote.deadline, DateTime.utc(2026, 9, 20, 6, 0, 50)); + expect(quote.deadline, DateTime.parse(sent['deadline'] as String)); expect(quote.timeEstimate, const Duration(seconds: 45)); expect(quote.correlationId, 'corr-1'); expect(quote.signature, startsWith('ed25519:')); @@ -176,13 +183,13 @@ void main() { await service.getQuote(from: btc, to: _wnear, amount: BigInt.one, refundAddress: _refund, recipient: _recipient); final deadline = DateTime.parse(sent['deadline'] as String); expect(SwapService.depositWindowFor('BTC'), const Duration(hours: 2)); - expect(deadline.difference(before), greaterThanOrEqualTo(const Duration(hours: 2))); + expect(deadline.difference(before), greaterThan(const Duration(hours: 2) - const Duration(seconds: 1))); expect(deadline.difference(before), lessThan(const Duration(hours: 2, seconds: 10))); }); test('rejects a quote whose signature does not cover its deposit address', () async { - final service = _service((_) async { - final tampered = _quoteResponse(dry: false, depositAddress: '0xdeposit'); + final service = _service((r) async { + final tampered = _quoteResponse(depositAddress: '0xdeposit', request: _sentRequest(r)); (tampered['quote'] as Map)['depositAddress'] = '0xattacker'; return http.Response(jsonEncode(tampered), 200); }); @@ -193,18 +200,45 @@ void main() { }); test('rejects a signed quote that answers a different request', () async { - final service = _service((_) async => http.Response(jsonEncode(_quoteResponse(request: {'amount': '999'})), 200)); + final service = _service( + (r) async => http.Response(jsonEncode(_quoteResponse(request: {..._sentRequest(r), 'amount': '999'})), 200), + ); await expectLater( _quote(service), throwsA(isA().having((e) => e.message, 'message', contains('amount'))), ); }); + test('rejects a quote that prices a different input amount', () async { + final service = _service((r) async { + final response = _quoteResponse(request: _sentRequest(r)); + (response['quote'] as Map)['amountIn'] = '999'; + return http.Response(jsonEncode(_signed(response)), 200); + }); + await expectLater( + _quote(service), + throwsA(isA().having((e) => e.message, 'message', contains('999'))), + ); + }); + + test('rejects a quote that echoes another deadline', () async { + final service = _service( + (r) async => http.Response( + jsonEncode(_quoteResponse(request: {..._sentRequest(r), 'deadline': '2026-01-01T00:00:00.000Z'})), + 200, + ), + ); + await expectLater( + _quote(service), + throwsA(isA().having((e) => e.message, 'message', contains('deadline'))), + ); + }); + test('sends no API key header when none is configured', () async { late http.Request request; final service = _service((r) async { request = r; - return http.Response(jsonEncode(_quoteResponse()), 200); + return http.Response(jsonEncode(_quoteResponse(request: _sentRequest(r))), 200); }); await _quote(service); expect(request.headers.containsKey('X-API-Key'), isFalse); @@ -253,7 +287,10 @@ void main() { final service = _service((r) async { final dry = (jsonDecode(r.body) as Map)['dry'] as bool; sentDry.add(dry); - return http.Response(jsonEncode(_quoteResponse(dry: dry, depositAddress: dry ? null : '0xdeposit')), 200); + return http.Response( + jsonEncode(_quoteResponse(dry: dry, depositAddress: dry ? null : '0xdeposit', request: _sentRequest(r))), + 200, + ); }); final order = await service.createSwap(await _quote(service)); @@ -261,7 +298,7 @@ void main() { expect(sentDry, [true, false]); expect(order.status, SwapStatus.pendingDeposit); expect(order.depositAddress, '0xdeposit'); - expect(order.quote.deadline, DateTime.utc(2026, 9, 20, 6, 10, 50)); + expect(order.quote.deadline.difference(DateTime.now()), greaterThan(const Duration(minutes: 29))); final saved = await service.getSavedLiveQuotes(); expect(saved, hasLength(1)); @@ -270,6 +307,24 @@ void main() { expect(order.amountOut, isNull); }); + test('refuses a live quote whose deposit deadline is too near', () async { + final service = _service((r) async { + final sent = _sentRequest(r); + final dry = sent['dry'] as bool; + final soon = DateTime.now().toUtc().add(const Duration(minutes: 1)).toIso8601String(); + return http.Response( + jsonEncode( + _quoteResponse(dry: dry, depositAddress: dry ? null : '0xdeposit', liveDeadline: soon, request: sent), + ), + 200, + ); + }); + await expectLater( + service.createSwap(await _quote(service)), + throwsA(isA().having((e) => e.message, 'message', contains('deadline'))), + ); + }); + test('refuses a live quote without a deposit address', () async { final service = _service((r) async => http.Response(jsonEncode(_quoteResponse(request: _sentRequest(r))), 200)); await expectLater(service.createSwap(await _quote(service)), throwsA(isA()));