From 0527b19ccc15a4aa0b4514054fbcc238052d1e7f Mon Sep 17 00:00:00 2001 From: AhmedHanafy725 Date: Wed, 17 Jun 2026 17:29:59 +0300 Subject: [PATCH 1/2] Add unlock feature --- app/lib/app_config.dart | 1 + app/lib/helpers/flags.dart | 2 + app/lib/helpers/globals.dart | 1 + app/lib/models/locked_token.dart | 38 +++ app/lib/screens/wallets/wallet_assets.dart | 4 +- app/lib/services/locked_tokens_service.dart | 229 ++++++++++++++++++ .../widgets/wallets/locked_tokens_card.dart | 193 +++++++++++++++ 7 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 app/lib/models/locked_token.dart create mode 100644 app/lib/services/locked_tokens_service.dart create mode 100644 app/lib/widgets/wallets/locked_tokens_card.dart diff --git a/app/lib/app_config.dart b/app/lib/app_config.dart index f1e45ea0e..02ada6c4e 100644 --- a/app/lib/app_config.dart +++ b/app/lib/app_config.dart @@ -228,4 +228,5 @@ void setFallbackConfigs() { Globals().newsUrl = ''; Globals().idenfyServiceUrl = ''; Globals().council = false; + Globals().showLockedTokens = false; } diff --git a/app/lib/helpers/flags.dart b/app/lib/helpers/flags.dart index c99101b72..2443c2e6e 100644 --- a/app/lib/helpers/flags.dart +++ b/app/lib/helpers/flags.dart @@ -93,6 +93,8 @@ class Flags { .toString(); Globals().council = await Flags().hasFlagValueByFeatureName('council-member'); + Globals().showLockedTokens = + await Flags().hasFlagValueByFeatureName('locked-tokens'); Globals().registrarURL = (await Flags().getFlagValueByFeatureName('registrar-url')).toString(); diff --git a/app/lib/helpers/globals.dart b/app/lib/helpers/globals.dart index 96956bc50..6a01822f5 100644 --- a/app/lib/helpers/globals.dart +++ b/app/lib/helpers/globals.dart @@ -63,6 +63,7 @@ class Globals { String newsUrl = ''; String idenfyServiceUrl = ''; bool council = false; + bool showLockedTokens = false; String registrarURL = ''; bool isCacheClearedWallet = false; bool isCacheClearedFarmer = false; diff --git a/app/lib/models/locked_token.dart b/app/lib/models/locked_token.dart new file mode 100644 index 000000000..21ef75614 --- /dev/null +++ b/app/lib/models/locked_token.dart @@ -0,0 +1,38 @@ +class LockedToken { + LockedToken({ + required this.address, + required this.assetCode, + required this.assetIssuer, + required this.amount, + required this.unlockHash, + required this.unlockFrom, + required this.canBeUnlocked, + }); + + /// The escrow (locked) Stellar account that holds the tokens. + final String address; + + /// The asset code of the locked balance (e.g. `TFT`). + final String assetCode; + + /// The issuer of the locked asset. + final String assetIssuer; + + /// The locked amount. + final double amount; + + /// The `preauth_tx` signer of the escrow account. `null` when the account can + /// be unlocked immediately (no time-lock left). + String? unlockHash; + + /// Unix timestamp (seconds) from which the tokens can be unlocked. `null` when + /// there is no time-lock. + final int? unlockFrom; + + /// Whether the tokens can currently be unlocked. + bool canBeUnlocked; + + DateTime? get unlockFromDate => unlockFrom == null + ? null + : DateTime.fromMillisecondsSinceEpoch(unlockFrom! * 1000); +} diff --git a/app/lib/screens/wallets/wallet_assets.dart b/app/lib/screens/wallets/wallet_assets.dart index 7f8147799..9486eaa64 100644 --- a/app/lib/screens/wallets/wallet_assets.dart +++ b/app/lib/screens/wallets/wallet_assets.dart @@ -12,6 +12,7 @@ import 'package:threebotlogin/services/stellar_service.dart' as Stellar; import 'package:threebotlogin/widgets/wallets/activate_wallet.dart'; import 'package:threebotlogin/widgets/wallets/arrow_inward.dart'; import 'package:threebotlogin/widgets/wallets/balance_tile.dart'; +import 'package:threebotlogin/widgets/wallets/locked_tokens_card.dart'; class WalletAssetsWidget extends StatefulWidget { const WalletAssetsWidget({super.key, required this.wallet}); @@ -231,7 +232,8 @@ class _WalletAssetsWidgetState extends State { loading: false, onActivate: _openActivateStellarOverlay, ), - ...vestWidgets + ...vestWidgets, + if (Globals().showLockedTokens) LockedTokensCard(wallet: widget.wallet), ], ), ); diff --git a/app/lib/services/locked_tokens_service.dart b/app/lib/services/locked_tokens_service.dart new file mode 100644 index 000000000..a788572cb --- /dev/null +++ b/app/lib/services/locked_tokens_service.dart @@ -0,0 +1,229 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/models/locked_token.dart'; + +/// Production ThreeFold token services endpoint that stores the unlock +/// (pre-authorized) transactions for time-locked escrow accounts. +const String _unlockServiceUrl = + 'https://tokenservices.threefold.io/threefoldfoundation'; + +/// Asset codes we consider when looking for locked balances. +const List _allowedAssetCodes = ['TFT', 'TFTA', 'FreeTFT']; + +final StellarSDK _sdk = StellarSDK.PUBLIC; +final Network _network = Network.PUBLIC; + +/// Discovers all escrow accounts that the wallet (identified by [stellarAddress]) +/// is a signer of, and returns the locked balances together with their unlock +/// information. +Future> getLockedTokens(String stellarAddress) async { + logger.i('[LockedTokens] Looking up escrow accounts for $stellarAddress'); + final Page accounts = + await _sdk.accounts.forSigner(stellarAddress).limit(200).execute(); + logger.i( + '[LockedTokens] forSigner returned ${accounts.records.length} signed account(s)'); + + final List lockedTokens = []; + for (final account in accounts.records) { + // Skip the wallet's own account. + if (account.accountId == stellarAddress) continue; + // Skip vesting accounts, those are handled separately. + if (account.data.keys.contains('tft-vesting')) continue; + + Balance? balance; + for (final b in account.balances) { + final amount = double.tryParse(b.balance) ?? 0; + if (b.assetType != 'native' && + b.assetCode != null && + b.assetIssuer != null && + _allowedAssetCodes.contains(b.assetCode) && + amount > 0) { + balance = b; + break; + } + } + if (balance == null) continue; + + String? unlockHash; + for (final signer in account.signers) { + if (signer.type == 'preauth_tx') { + unlockHash = signer.key; + break; + } + } + logger.i( + '[LockedTokens] Escrow ${account.accountId}: ${balance.balance} ${balance.assetCode}, ' + 'unlockHash=${unlockHash ?? 'none'}'); + + final detailed = await _getLockedTokenDetails( + address: account.accountId, + assetCode: balance.assetCode!, + assetIssuer: balance.assetIssuer!, + amount: double.parse(balance.balance), + unlockHash: unlockHash, + ); + if (detailed != null) lockedTokens.add(detailed); + } + + logger.i('[LockedTokens] Found ${lockedTokens.length} locked balance(s)'); + return lockedTokens; +} + +/// Builds a [LockedToken] for a single escrow account, resolving the unlock +/// time from the stored unlock transaction (if any). +Future _getLockedTokenDetails({ + required String address, + required String assetCode, + required String assetIssuer, + required double amount, + required String? unlockHash, +}) async { + // No pre-auth signer means the funds can be claimed immediately. + if (unlockHash == null) { + return LockedToken( + address: address, + assetCode: assetCode, + assetIssuer: assetIssuer, + amount: amount, + unlockHash: null, + unlockFrom: null, + canBeUnlocked: true, + ); + } + + try { + final unlockTx = await _fetchUnlockTransaction(unlockHash); + final minTime = unlockTx.preconditions?.timeBounds?.minTime; + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return LockedToken( + address: address, + assetCode: assetCode, + assetIssuer: assetIssuer, + amount: amount, + unlockHash: unlockHash, + unlockFrom: minTime, + canBeUnlocked: minTime != null && nowSeconds >= minTime, + ); + } catch (e) { + logger.e('Could not fetch unlock transaction for $address: $e'); + return null; + } +} + +/// Fetches the stored unlock (pre-authorized) transaction for [unlockHash] from +/// the ThreeFold unlock service and parses it from XDR. +Future _fetchUnlockTransaction(String unlockHash) async { + final response = await http.post( + Uri.parse('$_unlockServiceUrl/unlock_service/get_unlockhash_transaction'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'args': {'unlockhash': unlockHash} + }), + ); + + if (response.statusCode != 200) { + throw Exception( + 'Unlock service returned ${response.statusCode}: ${response.body}'); + } + + final data = jsonDecode(response.body); + final String xdr = data['transaction_xdr']; + final tx = AbstractTransaction.fromEnvelopeXdrString(xdr); + if (tx is! Transaction) { + throw Exception('Unexpected unlock transaction type'); + } + return tx; +} + +/// Unlocks the provided [lockedTokens] for the wallet owning [secret]. +/// +/// Returns the list of tokens that were successfully unlocked. Tokens whose +/// time-lock has not expired yet, or that fail to submit, are skipped. +Future> unlockTokens( + List lockedTokens, String secret) async { + final keyPair = KeyPair.fromSecretSeed(secret); + final List unlocked = []; + + for (final lockedToken in lockedTokens) { + try { + // Step 1: submit the pre-authorized unlock transaction (if still locked). + if (lockedToken.unlockHash != null) { + final submitted = await _submitUnlockTransaction(lockedToken); + if (!submitted) continue; + lockedToken.unlockHash = null; + } + + // Step 2: drain the escrow account into the main account. + await _transferLockedBalance(keyPair, lockedToken); + unlocked.add(lockedToken); + } catch (e) { + logger.e('Failed to unlock tokens from ${lockedToken.address}: $e'); + continue; + } + } + + return unlocked; +} + +/// Submits the stored unlock transaction to the Stellar network. Returns `false` +/// when the time-lock has not expired yet. +Future _submitUnlockTransaction(LockedToken lockedToken) async { + final unlockTx = await _fetchUnlockTransaction(lockedToken.unlockHash!); + final minTime = unlockTx.preconditions?.timeBounds?.minTime; + final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (minTime == null || nowSeconds < minTime) { + logger.i('Tokens from ${lockedToken.address} cannot be unlocked yet'); + return false; + } + + final response = await _sdk.submitTransaction(unlockTx); + if (!response.success) { + throw Exception('Failed to submit unlock transaction'); + } + return true; +} + +/// Transfers the locked balance from the escrow account back to the main +/// account: pays out the balance, removes the trustline and merges the escrow +/// account. All operations are sourced from the escrow account and signed by +/// the main keypair (which is an authorized signer on the escrow account). +Future _transferLockedBalance( + KeyPair keyPair, LockedToken lockedToken) async { + final asset = Asset.createNonNativeAsset( + lockedToken.assetCode, lockedToken.assetIssuer); + final account = await _sdk.accounts.account(keyPair.accountId); + + final builder = TransactionBuilder(account); + + if (lockedToken.amount > 0) { + builder.addOperation( + PaymentOperationBuilder( + keyPair.accountId, asset, lockedToken.amount.toStringAsFixed(7)) + .setSourceAccount(lockedToken.address) + .build(), + ); + } + + builder.addOperation( + ChangeTrustOperationBuilder(asset, '0') + .setSourceAccount(lockedToken.address) + .build(), + ); + + builder.addOperation( + AccountMergeOperationBuilder(keyPair.accountId) + .setSourceAccount(lockedToken.address) + .build(), + ); + + final transaction = builder.build(); + transaction.sign(keyPair, _network); + + final response = await _sdk.submitTransaction(transaction); + if (!response.success) { + throw Exception('Failed to transfer locked balance'); + } +} diff --git a/app/lib/widgets/wallets/locked_tokens_card.dart b/app/lib/widgets/wallets/locked_tokens_card.dart new file mode 100644 index 000000000..1b9efc416 --- /dev/null +++ b/app/lib/widgets/wallets/locked_tokens_card.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:threebotlogin/helpers/logger.dart'; +import 'package:threebotlogin/helpers/transaction_helpers.dart'; +import 'package:threebotlogin/models/locked_token.dart'; +import 'package:threebotlogin/models/wallet.dart'; +import 'package:threebotlogin/services/locked_tokens_service.dart' + as LockedTokens; +import 'package:threebotlogin/widgets/custom_dialog.dart'; + +class LockedTokensCard extends StatefulWidget { + const LockedTokensCard({super.key, required this.wallet}); + final Wallet wallet; + + @override + State createState() => _LockedTokensCardState(); +} + +class _LockedTokensCardState extends State { + List _lockedTokens = []; + bool _loading = true; + bool _unlocking = false; + + @override + void initState() { + super.initState(); + _loadLockedTokens(); + } + + Future _loadLockedTokens() async { + try { + final tokens = + await LockedTokens.getLockedTokens(widget.wallet.stellarAddress); + if (!mounted) return; + setState(() { + _lockedTokens = tokens; + _loading = false; + }); + } catch (e) { + logger.e('[LockedTokens] Failed to load locked tokens: $e'); + if (!mounted) return; + setState(() { + _lockedTokens = []; + _loading = false; + }); + } + } + + double get _totalLocked => + _lockedTokens.fold(0, (sum, t) => sum + t.amount); + + bool get _hasUnlockable => _lockedTokens.any((t) => t.canBeUnlocked); + + Future _unlock() async { + setState(() => _unlocking = true); + final unlockable = + _lockedTokens.where((t) => t.canBeUnlocked).toList(); + try { + final unlocked = + await LockedTokens.unlockTokens(unlockable, widget.wallet.stellarSecret); + if (!mounted) return; + if (unlocked.isEmpty) { + _showDialog( + DialogType.Warning, + 'Nothing unlocked', + 'The tokens could not be unlocked yet. Please try again later.', + ); + } else { + _showDialog( + DialogType.Info, + 'Tokens unlocked', + 'Your tokens have been unlocked and transferred to your wallet. ' + 'Your balance will update shortly.', + ); + } + } catch (e) { + if (!mounted) return; + _showDialog( + DialogType.Error, + 'Failed to unlock', + 'Something went wrong while unlocking your tokens. Please try again.', + ); + } finally { + if (mounted) setState(() => _unlocking = false); + await _loadLockedTokens(); + } + } + + void _showDialog(DialogType type, String title, String description) { + showDialog( + context: context, + builder: (BuildContext context) => CustomDialog( + type: type, + image: type == DialogType.Error + ? Icons.error + : type == DialogType.Warning + ? Icons.warning + : Icons.check_circle, + title: title, + description: description, + actions: [ + TextButton( + child: const Text('Close'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + // Hide the section entirely when there is nothing locked. + if (!_loading && _lockedTokens.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const SizedBox(height: 10), + Text( + 'Locked', + style: Theme.of(context).textTheme.headlineSmall!.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + if (_loading) + const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else ...[ + ListTile( + shape: RoundedRectangleBorder( + side: BorderSide(color: Theme.of(context).colorScheme.primary), + borderRadius: BorderRadius.circular(5), + ), + leading: Icon( + Icons.lock_clock, + color: Theme.of(context).colorScheme.primary, + ), + title: Text( + 'Locked TFT', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + trailing: Text( + '${formatAmount(_totalLocked.toString())} TFT', + style: Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + ), + onPressed: (_hasUnlockable && !_unlocking) ? _unlock : null, + child: _unlocking + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _hasUnlockable + ? 'Unlock tokens' + : 'Tokens are still locked', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + ), + ], + ], + ); + } +} From a1013f5794a0f36280bab11aae8eac551fc040e9 Mon Sep 17 00:00:00 2001 From: AhmedHanafy725 Date: Mon, 22 Jun 2026 15:02:29 +0300 Subject: [PATCH 2/2] Address review feedback on unlock feature - Drain escrow using its exact live balance string instead of a re-serialized double, avoiding stroop drift that could revert the drain transaction - Surface escrows as "locked, unlock time unknown" on token-service failures instead of silently dropping them; validate transaction_xdr in the response - Treat a preauth tx with no minTime as claimable immediately - Paginate forSigner so wallets signing >200 accounts are not truncated - Restrict locked assets to TFT to avoid multi-asset AccountMerge failures - Log address/escrow/balance details at debug level to keep PII out of release - Resolve escrow unlock details concurrently instead of serially - Report per-escrow unlock outcomes so an unlocked-but-undrained escrow tells the user to retry rather than reporting an outright failure - Hide the Locked section until loaded and non-empty to stop header flashing --- app/lib/services/locked_tokens_service.dart | 164 ++++++++++++++---- .../widgets/wallets/locked_tokens_card.dart | 58 ++++--- 2 files changed, 164 insertions(+), 58 deletions(-) diff --git a/app/lib/services/locked_tokens_service.dart b/app/lib/services/locked_tokens_service.dart index a788572cb..4b7543e6b 100644 --- a/app/lib/services/locked_tokens_service.dart +++ b/app/lib/services/locked_tokens_service.dart @@ -10,8 +10,11 @@ import 'package:threebotlogin/models/locked_token.dart'; const String _unlockServiceUrl = 'https://tokenservices.threefold.io/threefoldfoundation'; -/// Asset codes we consider when looking for locked balances. -const List _allowedAssetCodes = ['TFT', 'TFTA', 'FreeTFT']; +/// Asset codes we consider when looking for locked balances. Restricted to +/// `TFT` only: the UI represents a single "Locked TFT" balance, and allowing a +/// second asset on an escrow would leave a trailing trustline that makes the +/// `AccountMerge` in [_transferLockedBalance] fail. +const List _allowedAssetCodes = ['TFT']; final StellarSDK _sdk = StellarSDK.PUBLIC; final Network _network = Network.PUBLIC; @@ -20,14 +23,26 @@ final Network _network = Network.PUBLIC; /// is a signer of, and returns the locked balances together with their unlock /// information. Future> getLockedTokens(String stellarAddress) async { - logger.i('[LockedTokens] Looking up escrow accounts for $stellarAddress'); - final Page accounts = + logger.d('[LockedTokens] Looking up escrow accounts for $stellarAddress'); + + // Discover every account the wallet signs, following Horizon pagination so + // wallets signing more than one page of escrows are not silently truncated. + final http.Client httpClient = http.Client(); + final List signedAccounts = []; + Page? page = await _sdk.accounts.forSigner(stellarAddress).limit(200).execute(); - logger.i( - '[LockedTokens] forSigner returned ${accounts.records.length} signed account(s)'); + while (page != null && page.records.isNotEmpty) { + signedAccounts.addAll(page.records); + page = await page.getNextPage(httpClient); + } + logger.d( + '[LockedTokens] forSigner returned ${signedAccounts.length} signed account(s)'); - final List lockedTokens = []; - for (final account in accounts.records) { + // Resolve the unlock details for every escrow concurrently: each one may need + // a token-service round-trip, and doing them serially would block the Assets + // screen on the slowest escrow. + final List> pending = []; + for (final account in signedAccounts) { // Skip the wallet's own account. if (account.accountId == stellarAddress) continue; // Skip vesting accounts, those are handled separately. @@ -54,21 +69,23 @@ Future> getLockedTokens(String stellarAddress) async { break; } } - logger.i( + logger.d( '[LockedTokens] Escrow ${account.accountId}: ${balance.balance} ${balance.assetCode}, ' 'unlockHash=${unlockHash ?? 'none'}'); - final detailed = await _getLockedTokenDetails( + pending.add(_getLockedTokenDetails( address: account.accountId, assetCode: balance.assetCode!, assetIssuer: balance.assetIssuer!, amount: double.parse(balance.balance), unlockHash: unlockHash, - ); - if (detailed != null) lockedTokens.add(detailed); + )); } - logger.i('[LockedTokens] Found ${lockedTokens.length} locked balance(s)'); + final lockedTokens = + (await Future.wait(pending)).whereType().toList(); + + logger.d('[LockedTokens] Found ${lockedTokens.length} locked balance(s)'); return lockedTokens; } @@ -105,11 +122,23 @@ Future _getLockedTokenDetails({ amount: amount, unlockHash: unlockHash, unlockFrom: minTime, - canBeUnlocked: minTime != null && nowSeconds >= minTime, + // No lower time bound means the unlock tx is claimable immediately. + canBeUnlocked: minTime == null || nowSeconds >= minTime, ); } catch (e) { logger.e('Could not fetch unlock transaction for $address: $e'); - return null; + // A transient token-service failure must not make a real locked balance + // disappear from the list. Surface it as locked with an unknown unlock + // time instead of dropping it; the unlock button stays disabled. + return LockedToken( + address: address, + assetCode: assetCode, + assetIssuer: assetIssuer, + amount: amount, + unlockHash: unlockHash, + unlockFrom: null, + canBeUnlocked: false, + ); } } @@ -130,7 +159,10 @@ Future _fetchUnlockTransaction(String unlockHash) async { } final data = jsonDecode(response.body); - final String xdr = data['transaction_xdr']; + final xdr = data['transaction_xdr']; + if (xdr is! String || xdr.isEmpty) { + throw Exception('Unlock service response missing transaction_xdr'); + } final tx = AbstractTransaction.fromEnvelopeXdrString(xdr); if (tx is! Transaction) { throw Exception('Unexpected unlock transaction type'); @@ -138,34 +170,77 @@ Future _fetchUnlockTransaction(String unlockHash) async { return tx; } +/// The per-escrow outcome of an [unlockTokens] attempt. +enum UnlockOutcome { + /// The escrow was unlocked and fully drained into the main wallet. + unlocked, + + /// The time-lock has not expired yet; nothing was submitted on-chain. + notYet, + + /// The pre-authorized unlock was submitted (so the escrow is unlocked + /// on-chain) but the follow-up drain transaction failed. The funds are not + /// lost — retrying will claim them, since the escrow now has no time-lock. + unlockedButTransferFailed, + + /// The unlock could not even be submitted; nothing changed on-chain. + failed, +} + +/// The result of attempting to unlock a single [LockedToken]. +class UnlockResult { + UnlockResult(this.token, this.outcome); + + final LockedToken token; + final UnlockOutcome outcome; +} + /// Unlocks the provided [lockedTokens] for the wallet owning [secret]. /// -/// Returns the list of tokens that were successfully unlocked. Tokens whose -/// time-lock has not expired yet, or that fail to submit, are skipped. -Future> unlockTokens( +/// Returns one [UnlockResult] per input token describing what happened. The +/// unlock is a two-step, non-atomic process: submitting the pre-authorized +/// transaction (step 1) is irreversible and consumes the escrow's `preauth_tx` +/// signer, after which the balance still has to be drained (step 2). When step +/// 2 fails the escrow is left unlocked-but-undrained — reported distinctly as +/// [UnlockOutcome.unlockedButTransferFailed] so the UI can tell the user to +/// retry to claim, rather than reporting an outright failure. +Future> unlockTokens( List lockedTokens, String secret) async { final keyPair = KeyPair.fromSecretSeed(secret); - final List unlocked = []; + final List results = []; for (final lockedToken in lockedTokens) { - try { - // Step 1: submit the pre-authorized unlock transaction (if still locked). - if (lockedToken.unlockHash != null) { - final submitted = await _submitUnlockTransaction(lockedToken); - if (!submitted) continue; - lockedToken.unlockHash = null; + // Step 1: submit the pre-authorized unlock transaction (if still locked). + if (lockedToken.unlockHash != null) { + bool submitted; + try { + submitted = await _submitUnlockTransaction(lockedToken); + } catch (e) { + logger.e('Failed to submit unlock tx for ${lockedToken.address}: $e'); + results.add(UnlockResult(lockedToken, UnlockOutcome.failed)); + continue; + } + if (!submitted) { + results.add(UnlockResult(lockedToken, UnlockOutcome.notYet)); + continue; } + // The pre-auth signer is now consumed: the escrow is unlocked on-chain. + lockedToken.unlockHash = null; + } - // Step 2: drain the escrow account into the main account. + // Step 2: drain the escrow account into the main account. A failure here + // leaves the escrow unlocked but undrained; a later retry will claim it. + try { await _transferLockedBalance(keyPair, lockedToken); - unlocked.add(lockedToken); + results.add(UnlockResult(lockedToken, UnlockOutcome.unlocked)); } catch (e) { - logger.e('Failed to unlock tokens from ${lockedToken.address}: $e'); - continue; + logger.e('Failed to transfer balance from ${lockedToken.address}: $e'); + results + .add(UnlockResult(lockedToken, UnlockOutcome.unlockedButTransferFailed)); } } - return unlocked; + return results; } /// Submits the stored unlock transaction to the Stellar network. Returns `false` @@ -174,8 +249,10 @@ Future _submitUnlockTransaction(LockedToken lockedToken) async { final unlockTx = await _fetchUnlockTransaction(lockedToken.unlockHash!); final minTime = unlockTx.preconditions?.timeBounds?.minTime; final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000; - if (minTime == null || nowSeconds < minTime) { - logger.i('Tokens from ${lockedToken.address} cannot be unlocked yet'); + // A null lower bound means it is claimable immediately; only block when a + // time-lock exists and has not expired yet. + if (minTime != null && nowSeconds < minTime) { + logger.d('Tokens from ${lockedToken.address} cannot be unlocked yet'); return false; } @@ -196,12 +273,27 @@ Future _transferLockedBalance( lockedToken.assetCode, lockedToken.assetIssuer); final account = await _sdk.accounts.account(keyPair.accountId); + // Read the exact balance string straight from the escrow at drain time. + // Stellar amounts are 7-dp fixed point and a `double` only represents every + // stroop exactly below ~900M TFT; re-serializing the listed `double` could + // leave dust (ChangeTrust('0') fails) or overpay (op_underfunded). Using the + // live string keeps the payout stroop-exact so the account can be merged. + final escrow = await _sdk.accounts.account(lockedToken.address); + String? balanceString; + for (final b in escrow.balances) { + if (b.assetType != 'native' && + b.assetCode == lockedToken.assetCode && + b.assetIssuer == lockedToken.assetIssuer) { + balanceString = b.balance; + break; + } + } + final builder = TransactionBuilder(account); - if (lockedToken.amount > 0) { + if (balanceString != null && (double.tryParse(balanceString) ?? 0) > 0) { builder.addOperation( - PaymentOperationBuilder( - keyPair.accountId, asset, lockedToken.amount.toStringAsFixed(7)) + PaymentOperationBuilder(keyPair.accountId, asset, balanceString) .setSourceAccount(lockedToken.address) .build(), ); diff --git a/app/lib/widgets/wallets/locked_tokens_card.dart b/app/lib/widgets/wallets/locked_tokens_card.dart index 1b9efc416..63e096e1f 100644 --- a/app/lib/widgets/wallets/locked_tokens_card.dart +++ b/app/lib/widgets/wallets/locked_tokens_card.dart @@ -55,22 +55,47 @@ class _LockedTokensCardState extends State { final unlockable = _lockedTokens.where((t) => t.canBeUnlocked).toList(); try { - final unlocked = - await LockedTokens.unlockTokens(unlockable, widget.wallet.stellarSecret); + final results = await LockedTokens.unlockTokens( + unlockable, widget.wallet.stellarSecret); if (!mounted) return; - if (unlocked.isEmpty) { + + final unlockedCount = results + .where((r) => r.outcome == LockedTokens.UnlockOutcome.unlocked) + .length; + final transferFailed = results.any((r) => + r.outcome == LockedTokens.UnlockOutcome.unlockedButTransferFailed); + final failed = results + .any((r) => r.outcome == LockedTokens.UnlockOutcome.failed); + + if (transferFailed) { + // The escrow was unlocked on-chain but the funds were not transferred; + // a retry will claim them, so steer the user to try again rather than + // reporting an outright failure. _showDialog( DialogType.Warning, - 'Nothing unlocked', - 'The tokens could not be unlocked yet. Please try again later.', + 'Almost there', + 'Your tokens were unlocked but could not be transferred to your ' + 'wallet yet. Please try again to claim them.', ); - } else { + } else if (unlockedCount > 0) { _showDialog( DialogType.Info, 'Tokens unlocked', 'Your tokens have been unlocked and transferred to your wallet. ' 'Your balance will update shortly.', ); + } else if (failed) { + _showDialog( + DialogType.Error, + 'Failed to unlock', + 'Something went wrong while unlocking your tokens. Please try again.', + ); + } else { + _showDialog( + DialogType.Warning, + 'Nothing unlocked', + 'The tokens could not be unlocked yet. Please try again later.', + ); } } catch (e) { if (!mounted) return; @@ -109,8 +134,10 @@ class _LockedTokensCardState extends State { @override Widget build(BuildContext context) { - // Hide the section entirely when there is nothing locked. - if (!_loading && _lockedTokens.isEmpty) { + // Render nothing until tokens are loaded, and stay hidden for the common + // "no locked tokens" case. This avoids flashing the Divider + 'Locked' + // header + spinner on every wallet open before collapsing again. + if (_loading || _lockedTokens.isEmpty) { return const SizedBox.shrink(); } @@ -126,19 +153,7 @@ class _LockedTokensCardState extends State { fontWeight: FontWeight.bold), ), const SizedBox(height: 20), - if (_loading) - const Center( - child: Padding( - padding: EdgeInsets.all(20), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else ...[ - ListTile( + ListTile( shape: RoundedRectangleBorder( side: BorderSide(color: Theme.of(context).colorScheme.primary), borderRadius: BorderRadius.circular(5), @@ -186,7 +201,6 @@ class _LockedTokensCardState extends State { ), ), ), - ], ], ); }