diff --git a/packages/komodo_defi_sdk/example/lib/screens/withdrawal_page.dart b/packages/komodo_defi_sdk/example/lib/screens/withdrawal_page.dart index 55dc5a9d5..eefedfabb 100644 --- a/packages/komodo_defi_sdk/example/lib/screens/withdrawal_page.dart +++ b/packages/komodo_defi_sdk/example/lib/screens/withdrawal_page.dart @@ -128,9 +128,8 @@ class _WithdrawalScreenState extends State { if (!_addressValidation!.isValid) { setState( - () => - _error = - _addressValidation!.invalidReason ?? 'Invalid address format', + () => _error = + _addressValidation!.invalidReason ?? 'Invalid address format', ); return; } @@ -146,17 +145,17 @@ class _WithdrawalScreenState extends State { amount: _isMaxAmount ? null : Decimal.parse(_amountController.text), fee: _selectedFee, feePriority: _selectedPriority, - from: - _selectedFromAddress?.derivationPath != null - ? WithdrawalSource.hdDerivationPath( - _selectedFromAddress!.derivationPath!, - ) - : null, + from: _selectedFromAddress?.derivationPath != null + ? WithdrawalSource.hdDerivationPath( + _selectedFromAddress!.derivationPath!, + ) + : null, memo: _memoController.text.isEmpty ? null : _memoController.text, isMax: _isMaxAmount, ibcTransfer: _isIbcTransfer ? true : null, - ibcSourceChannel: - _isIbcTransfer ? int.tryParse(_ibcChannelController.text) : null, + ibcSourceChannel: _isIbcTransfer + ? int.tryParse(_ibcChannelController.text) + : null, ); final preview = await _sdk.withdrawals.previewWithdrawal(params); @@ -178,48 +177,50 @@ class _WithdrawalScreenState extends State { Future _showPreviewDialog(WithdrawParameters params) async { return showDialog( context: context, - builder: - (context) => AlertDialog( - title: const Text('Withdrawal Preview'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Asset: ${params.asset}'), - Text('To: ${params.toAddress}'), - if (params.amount != null) - Text('Amount: ${params.amount} ${params.asset}'), - if (_selectedFee != null) ...[ - const SizedBox(height: 8), - FeeInfoDisplay(feeInfo: _selectedFee!), - ], - if (_preview != null) ...[ - const SizedBox(height: 8), - Text('Estimated fee: ${_preview!.fee.formatTotal()}'), - Text('Balance change: ${_preview!.balanceChanges.netChange}'), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(context).pop(); - _executeWithdrawal(params); - }, - child: const Text('Confirm'), - ), + builder: (context) => AlertDialog( + title: const Text('Withdrawal Preview'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Asset: ${params.asset}'), + Text('To: ${params.toAddress}'), + if (params.amount != null) + Text('Amount: ${params.amount} ${params.asset}'), + if (_selectedFee != null) ...[ + const SizedBox(height: 8), + FeeInfoDisplay(feeInfo: _selectedFee!), ], + if (_preview != null) ...[ + const SizedBox(height: 8), + Text('Estimated fee: ${_preview!.fee.formatTotal()}'), + Text('Balance change: ${_preview!.balanceChanges.netChange}'), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(context).pop(); + _executeWithdrawal(params); + }, + child: const Text('Confirm'), ), + ], + ), ); } Future _executeWithdrawal(WithdrawParameters params) async { try { - final progressStream = _sdk.withdrawals.withdraw(params); + // Execute the previewed withdrawal (transaction already signed) + final progressStream = _preview != null + ? _sdk.withdrawals.executeWithdrawal(_preview!, params.asset) + : _sdk.withdrawals.withdraw(params); await for (final progress in progressStream) { if (!mounted) return; @@ -349,13 +350,12 @@ class _WithdrawalScreenState extends State { ), CheckboxListTile( value: _isMaxAmount, - onChanged: - (value) => setState(() { - _isMaxAmount = value == true; - if (_isMaxAmount) { - _amountController.clear(); - } - }), + onChanged: (value) => setState(() { + _isMaxAmount = value == true; + if (_isMaxAmount) { + _amountController.clear(); + } + }), title: const Text('Send maximum amount'), ), const SizedBox(height: 16), @@ -368,8 +368,9 @@ class _WithdrawalScreenState extends State { setState(() { _selectedPriority = priority; if (_feeOptions != null) { - _selectedFee = - _feeOptions!.getByPriority(priority).feeInfo; + _selectedFee = _feeOptions! + .getByPriority(priority) + .feeInfo; } }); }, @@ -454,10 +455,9 @@ class _WithdrawalScreenState extends State { return Icon( _addressValidation!.isValid ? Icons.check_circle : Icons.error, - color: - _addressValidation!.isValid - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.error, + color: _addressValidation!.isValid + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, ); } diff --git a/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart b/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart index 9a7eb7316..258be0596 100644 --- a/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/withdrawals/legacy_withdrawal_manager.dart @@ -10,7 +10,13 @@ class LegacyWithdrawalManager implements WithdrawalManager { final ApiClient _client; - /// Start a withdrawal operation and return a progress stream + /// Creates a preview and immediately executes the withdrawal. + /// + /// **DEPRECATED:** Use [previewWithdrawal] followed by [executeWithdrawal] + /// instead to ensure users can review transaction details before broadcasting. + @Deprecated( + 'Use previewWithdrawal() followed by executeWithdrawal() instead.', + ) @override Stream withdraw(WithdrawParameters parameters) async* { try { @@ -125,6 +131,70 @@ class LegacyWithdrawalManager implements WithdrawalManager { } } + /// Execute a withdrawal from a previously generated preview. + /// + /// This method broadcasts the pre-signed transaction from the preview, + /// avoiding the need to sign the transaction again. This is the ONLY + /// recommended way to execute withdrawals for Tendermint assets. + /// + /// Parameters: + /// - [preview] - The preview result from [previewWithdrawal] + /// - [assetId] - The asset identifier (coin symbol) + /// + /// Returns a [Stream] that emits progress updates. + @override + Stream executeWithdrawal( + WithdrawalPreview preview, + String assetId, + ) async* { + try { + // Initial progress update + yield WithdrawalProgress( + status: WithdrawalStatus.inProgress, + message: 'Broadcasting signed transaction...', + withdrawalResult: WithdrawalResult( + txHash: preview.txHash, + balanceChanges: preview.balanceChanges, + coin: assetId, + toAddress: preview.to.first, + fee: preview.fee, + kmdRewardsEligible: + preview.kmdRewards != null && + Decimal.parse(preview.kmdRewards!.amount) > Decimal.zero, + ), + ); + + // Broadcast the pre-signed transaction + final broadcastResponse = await _client.rpc.withdraw.sendRawTransaction( + coin: assetId, + txHex: preview.txHex, + ); + + // Final success update with actual broadcast transaction hash + yield WithdrawalProgress( + status: WithdrawalStatus.complete, + message: 'Withdrawal completed successfully', + withdrawalResult: WithdrawalResult( + txHash: broadcastResponse.txHash, + balanceChanges: preview.balanceChanges, + coin: assetId, + toAddress: preview.to.first, + fee: preview.fee, + kmdRewardsEligible: + preview.kmdRewards != null && + Decimal.parse(preview.kmdRewards!.amount) > Decimal.zero, + ), + ); + } catch (e) { + yield* Stream.error( + WithdrawalException( + 'Failed to broadcast transaction: $e', + WithdrawalErrorCode.networkError, + ), + ); + } + } + /// No-op for legacy implementation since there's no task to cancel @override Future cancelWithdrawal(int taskId) async => false; diff --git a/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart b/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart index 5e48265b9..9d00a0b42 100644 --- a/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart +++ b/packages/komodo_defi_sdk/lib/src/withdrawals/withdrawal_manager.dart @@ -537,46 +537,30 @@ class WithdrawalManager { } } - /// Executes a withdrawal operation and provides a progress stream. + /// Executes a withdrawal from a previously generated preview. /// - /// This method performs the full withdrawal process: - /// 1. Ensures the asset is activated - /// 2. Creates the transaction - /// 3. Broadcasts it to the network - /// 4. Tracks and reports progress + /// This method broadcasts a transaction that was already signed during the + /// preview phase. This is the ONLY recommended way to execute withdrawals, + /// as it ensures: + /// - The transaction is signed only once + /// - The user sees and confirms the exact transaction that will be broadcast + /// - No risk of parameters changing between preview and execution /// - /// **Note:** Fee estimation is currently disabled as the API endpoints are not yet available. - /// When fee estimation is disabled, withdrawals will proceed without automatic fee estimation. - /// TODO: Enable when the fee estimation API endpoints become available. + /// **Workflow:** + /// 1. Call [previewWithdrawal] to generate and sign the transaction + /// 2. Show the preview to the user for confirmation + /// 3. Call [executeWithdrawal] to broadcast the signed transaction /// /// Parameters: - /// - [parameters] - The withdrawal parameters defining the asset, amount, - /// destination, and optional fee priority - /// - /// Returns a [Stream] that emits progress updates - /// throughout the operation. The final event will either contain the - /// completed withdrawal result or an error. + /// - [preview] - The preview result from [previewWithdrawal] + /// - [assetId] - The asset identifier (coin symbol) /// - /// Fee Priority: - /// - If no fee is specified, the method will estimate fees based on the - /// feePriority parameter (defaults to medium) when fee estimation is enabled - /// - Low: Lowest cost, slowest confirmation - /// - Medium: Balanced cost and confirmation time - /// - High: Highest cost, fastest confirmation - /// - /// Error handling: - /// - Errors are emitted through the stream's error channel - /// - All errors are wrapped in [WithdrawalException] with appropriate - /// error codes - /// - /// Protocol handling: - /// - For Tendermint-based assets, this method uses a legacy implementation - /// - For other asset types, it uses the task-based API + /// Returns a [Stream] that emits progress updates. /// /// Example: /// ```dart - /// // Basic withdrawal with default (medium) priority - /// final progressStream = withdrawalManager.withdraw( + /// // 1. Preview the withdrawal + /// final preview = await withdrawalManager.previewWithdrawal( /// WithdrawParameters( /// asset: 'BTC', /// toAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', @@ -584,29 +568,122 @@ class WithdrawalManager { /// ), /// ); /// - /// // Withdrawal with high priority for faster confirmation - /// final fastProgressStream = withdrawalManager.withdraw( - /// WithdrawParameters( - /// asset: 'BTC', - /// toAddress: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', - /// amount: Decimal.parse('0.001'), - /// feePriority: WithdrawalFeeLevel.high, - /// ), - /// ); + /// // 2. Show preview to user, get confirmation... /// - /// try { - /// await for (final progress in progressStream) { - /// if (progress.status == WithdrawalStatus.complete) { - /// final result = progress.withdrawalResult!; - /// print('Withdrawal complete! TX: ${result.txHash}'); - /// } else { - /// print('Progress: ${progress.message}'); - /// } - /// } - /// } catch (e) { - /// print('Withdrawal failed: $e'); + /// // 3. Execute the previewed transaction + /// await for (final progress in withdrawalManager.executeWithdrawal( + /// preview, + /// 'BTC', + /// )) { + /// print('Status: ${progress.status}'); /// } /// ``` + Stream executeWithdrawal( + WithdrawalPreview preview, + String assetId, + ) async* { + try { + final asset = _assetProvider.findAssetsByConfigId(assetId).single; + final isTendermintProtocol = asset.protocol is TendermintProtocol; + + // Tendermint assets are not yet supported by the task-based API + if (isTendermintProtocol) { + yield* _legacyManager.executeWithdrawal(preview, assetId); + return; + } + + // Ensure asset is activated before broadcasting + final activationResult = await _activationCoordinator.activateAsset(asset); + if (activationResult.isFailure) { + throw WithdrawalException( + 'Failed to activate asset $assetId: ${activationResult.errorMessage ?? activationResult.toString()}', + WithdrawalErrorCode.unknownError, + ); + } + + // Initial progress + yield WithdrawalProgress( + status: WithdrawalStatus.inProgress, + message: 'Broadcasting signed transaction...', + withdrawalResult: WithdrawalResult( + txHash: preview.txHash, + balanceChanges: preview.balanceChanges, + coin: assetId, + toAddress: preview.to.first, + fee: preview.fee, + kmdRewardsEligible: + preview.kmdRewards != null && + Decimal.parse(preview.kmdRewards!.amount) > Decimal.zero, + ), + ); + + // Broadcast the pre-signed transaction + final response = await _client.rpc.withdraw.sendRawTransaction( + coin: assetId, + txHex: preview.txHex, + ); + + // Final success + yield WithdrawalProgress( + status: WithdrawalStatus.complete, + message: 'Withdrawal complete', + withdrawalResult: WithdrawalResult( + txHash: response.txHash, + balanceChanges: preview.balanceChanges, + coin: assetId, + toAddress: preview.to.first, + fee: preview.fee, + kmdRewardsEligible: + preview.kmdRewards != null && + Decimal.parse(preview.kmdRewards!.amount) > Decimal.zero, + ), + ); + } catch (e) { + yield* Stream.error( + WithdrawalException( + 'Failed to broadcast transaction: $e', + WithdrawalErrorCode.networkError, + ), + ); + } + } + + /// Creates a preview and immediately executes the withdrawal. + /// + /// **DEPRECATED:** This method is provided for convenience but is NOT the + /// recommended approach. It's better to use the two-step process: + /// 1. [previewWithdrawal] - Generate and show preview to user + /// 2. [executeWithdrawal] - Execute after user confirmation + /// + /// This ensures users can review the transaction details (fees, amounts) + /// before broadcasting. + /// + /// This method performs the full withdrawal process: + /// 1. Ensures the asset is activated + /// 2. Creates and signs the transaction + /// 3. Broadcasts it to the network + /// 4. Tracks and reports progress + /// + /// Parameters: + /// - [parameters] - The withdrawal parameters defining the asset, amount, + /// destination, and optional fee priority + /// + /// Returns a [Stream] that emits progress updates. + /// + /// **Recommended alternative:** + /// ```dart + /// // Instead of this: + /// await for (final progress in manager.withdraw(params)) { } + /// + /// // Do this: + /// final preview = await manager.previewWithdrawal(params); + /// // Show preview to user... + /// await for (final progress in manager.executeWithdrawal(preview, assetId)) { } + /// ``` + @Deprecated( + 'Use previewWithdrawal() followed by executeWithdrawal() instead. ' + 'This ensures users can review transaction details before broadcasting.', + ) Stream withdraw(WithdrawParameters parameters) async* { int? taskId; try {