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..a788572c --- /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 00000000..1b9efc41 --- /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, + ), + ), + ), + ), + ], + ], + ); + } +}