diff --git a/app/lib/app_config.dart b/app/lib/app_config.dart index f1e45ea0..02ada6c4 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 c99101b7..2443c2e6 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 96956bc5..6a01822f 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 00000000..21ef7561 --- /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 7f814779..9486eaa6 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 00000000..4b7543e6 --- /dev/null +++ b/app/lib/services/locked_tokens_service.dart @@ -0,0 +1,321 @@ +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. 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; + +/// 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.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(); + 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)'); + + // 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. + 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.d( + '[LockedTokens] Escrow ${account.accountId}: ${balance.balance} ${balance.assetCode}, ' + 'unlockHash=${unlockHash ?? 'none'}'); + + pending.add(_getLockedTokenDetails( + address: account.accountId, + assetCode: balance.assetCode!, + assetIssuer: balance.assetIssuer!, + amount: double.parse(balance.balance), + unlockHash: unlockHash, + )); + } + + final lockedTokens = + (await Future.wait(pending)).whereType().toList(); + + logger.d('[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, + // 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'); + // 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, + ); + } +} + +/// 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 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'); + } + 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 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 results = []; + + for (final lockedToken in lockedTokens) { + // 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. A failure here + // leaves the escrow unlocked but undrained; a later retry will claim it. + try { + await _transferLockedBalance(keyPair, lockedToken); + results.add(UnlockResult(lockedToken, UnlockOutcome.unlocked)); + } catch (e) { + logger.e('Failed to transfer balance from ${lockedToken.address}: $e'); + results + .add(UnlockResult(lockedToken, UnlockOutcome.unlockedButTransferFailed)); + } + } + + return results; +} + +/// 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; + // 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; + } + + 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); + + // 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 (balanceString != null && (double.tryParse(balanceString) ?? 0) > 0) { + builder.addOperation( + PaymentOperationBuilder(keyPair.accountId, asset, balanceString) + .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 00000000..63e096e1 --- /dev/null +++ b/app/lib/widgets/wallets/locked_tokens_card.dart @@ -0,0 +1,207 @@ +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 results = await LockedTokens.unlockTokens( + unlockable, widget.wallet.stellarSecret); + if (!mounted) return; + + 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, + 'Almost there', + 'Your tokens were unlocked but could not be transferred to your ' + 'wallet yet. Please try again to claim them.', + ); + } 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; + _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) { + // 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(); + } + + 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), + 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, + ), + ), + ), + ), + ], + ); + } +}