diff --git a/packages/stellar_client/lib/src/client.dart b/packages/stellar_client/lib/src/client.dart index ed5606c..202482d 100644 --- a/packages/stellar_client/lib/src/client.dart +++ b/packages/stellar_client/lib/src/client.dart @@ -49,10 +49,12 @@ class Client { void _initialize() { late final currency.Currency tft; + late final currency.Currency usdc; _serviceUrls = { 'PUBLIC': 'https://tokenservices.threefold.io/threefoldfoundation', 'TESTNET': 'https://testnet.threefold.io/threefoldfoundation' }; + switch (_network) { case NetworkType.TESTNET: _sdk = StellarSDK.TESTNET; @@ -61,6 +63,9 @@ class Client { assetCode: 'TFT', issuer: "GA47YZA3PKFUZMPLQ3B5F2E3CJIB57TGGU7SPCQT2WAEYKN766PWIMB3", ); + usdc = currency.Currency( + assetCode: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); break; case NetworkType.PUBLIC: _sdk = StellarSDK.PUBLIC; @@ -69,10 +74,17 @@ class Client { assetCode: 'TFT', issuer: "GBOVQKJYHXRR3DX6NOX2RRYFRCUMSADGDESTDNBDS6CDVLGVESRTAC47", ); + usdc = currency.Currency( + assetCode: 'USDC', + issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'); break; } - _currencies = currency.Currencies({'TFT': tft}); + _currencies = currency.Currencies({ + 'TFT': tft, + 'USDC': usdc, + 'XLM': currency.Currency(assetCode: 'XLM', issuer: "") + }); } Future activateThroughThreefoldService() async { @@ -130,10 +142,29 @@ class Client { } } - Future addTrustLine() async { + /// Adds trustline for all non-native assets in the `_currencies.currencies` map. + /// + /// Trustlines are required to hold non-native assets on a Stellar account. + /// This function iterates over all available currencies and attempts to + /// establish trustlines for each, except for the native asset (`XLM`). + /// + /// **Note:** Adding trustline requires having XLM in the account + /// + /// ### Returns: + /// - `true` if all trustlines were successfully added. + /// - `false` if one or more trustlines failed. + Future addConfiguredTrustlines() async { + bool allTrustlinesAdded = true; + for (var entry in _currencies.currencies.entries) { String currencyCode = entry.key; currency.Currency currentCurrency = entry.value; + if (currencyCode == 'XLM') { + logger.i("Skipping trustline for native asset $currencyCode"); + continue; + } + logger.i( + "Processing trustline for ${entry.key} with issuer ${entry.value.issuer}"); String issuerAccountId = currentCurrency.issuer; Asset currencyAsset = @@ -154,17 +185,26 @@ class Client { if (!response.success) { logger.e("Failed to add trustline for $currencyCode"); - return false; + allTrustlinesAdded = false; } else { - logger.i("trustline for $currencyCode was added successfully"); - return true; + logger.i("Trustline for $currencyCode was added successfully"); } } - logger.i("No trustlines were processed"); - return false; + if (allTrustlinesAdded) { + logger.i("All trustlines were added successfully"); + return true; + } else { + logger.e("One or more trustlines failed to be added"); + return false; + } } + /// Transfers a specified amount of currency to a destination address. + /// + /// This function builds a Stellar transaction to send funds from the current account + /// to a given recipient. It supports optional memo fields for additional transaction details. + /// **Note:** Transfer requires having XLM in the account Future transfer( {required String destinationAddress, required String amount, @@ -231,7 +271,6 @@ class Client { ); final data = jsonDecode(response.body); - String trustlineTransaction = data['addtrustline_transaction']; XdrTransactionEnvelope xdrTxEnvelope = XdrTransactionEnvelope.fromEnvelopeXdrString(trustlineTransaction); @@ -518,4 +557,269 @@ class Client { throw Exception("Couldn't get memo text due to ${e}"); } } + + Asset _getAsset(String assetCode) { + if (assetCode == 'XLM') { + return AssetTypeNative(); + } + + final asset = _currencies.currencies[assetCode]; + if (asset == null) { + throw Exception('Asset $assetCode is not available'); + } + + return AssetTypeCreditAlphaNum4(asset.assetCode, asset.issuer); + } + + /// Creates a DEX order by submitting a `ManageBuyOfferOperation` transaction. + /// + /// This function allows user to create an order to buy a specified asset + /// using another asset on Stellar network. + /// + /// **Note:** Creating an order requires having XLM in the account + /// to cover transaction fees and reserve requirements. + /// + /// **Price Format:** + /// - The `price` should always include a leading zero for decimal values. + /// - For example, instead of writing `.1`, the price should be written as `0.1`. + /// - **Correct format**: `0.1` + /// - **Incorrect format**: `.1` + Future createOrder({ + required String sellingAssetCode, + required String buyingAssetCode, + required String amount, + required String price, + String? memo, + }) async { + if (!_currencies.currencies.containsKey(sellingAssetCode)) { + throw Exception('Sell asset $sellingAssetCode is not available.'); + } + if (!_currencies.currencies.containsKey(buyingAssetCode)) { + throw Exception('Buy asset $buyingAssetCode is not available.'); + } + + final Asset sellingAsset = _getAsset(sellingAssetCode); + final Asset buyingAsset = _getAsset(buyingAssetCode); + + final ManageSellOfferOperation sellOfferOperation = + ManageSellOfferOperationBuilder( + sellingAsset, buyingAsset, amount, price) + .build(); + + final account = await _sdk.accounts.account(accountId); + final balances = account.balances; + + try { + Balance? sellAssetBalance; + Balance? buyAssetBalance; + + for (final balance in balances) { + if (sellAssetBalance == null) { + if (sellingAssetCode == 'XLM' && balance.assetCode == null) { + sellAssetBalance = balance; + } else if (balance.assetCode == sellingAssetCode) { + sellAssetBalance = balance; + } + } + + if (buyingAssetCode != 'XLM' && buyAssetBalance == null) { + if (balance.assetCode == buyingAssetCode) { + buyAssetBalance = balance; + } + } + + if (sellAssetBalance != null && + (buyingAssetCode == 'XLM' || buyAssetBalance != null)) { + break; + } + } + + if (sellAssetBalance == null) { + logger.e("Sell asset $sellingAssetCode not found in balances."); + throw Exception('Insufficient balance in $sellingAssetCode'); + } + + if (buyingAssetCode != 'XLM' && buyAssetBalance == null) { + logger.e("Buy asset $buyingAssetCode not found in balances."); + throw Exception('No trustline for $buyingAssetCode'); + } + + final double sellAmount = double.parse(amount); + final double availableBalance = double.parse(sellAssetBalance.balance); + + if (sellAmount > availableBalance) { + throw Exception( + 'Insufficient balance in $sellingAssetCode. Available: $availableBalance'); + } + } catch (e) { + logger.e("Error: ${e.toString()}"); + rethrow; + } + + final Transaction transaction = TransactionBuilder(account) + .addOperation(sellOfferOperation) + .addMemo(memo != null ? Memo.text(memo) : Memo.none()) + .build(); + + transaction.sign(_keyPair, _stellarNetwork); + try { + final SubmitTransactionResponse response = + await _sdk.submitTransaction(transaction); + if (!response.success) { + logger.e('Transaction failed with result: ${response.resultXdr}'); + return false; + } + return true; + } catch (error) { + throw Exception('Transaction failed due to: ${error.toString()}'); + } + } + + /// Cancels a DEX order by submitting a `ManageBuyOfferOperation` transaction with zero amount. + /// + /// This function allows user to cancel previously created order with its offerId. + /// + /// **Note:** Cancelling an order requires having XLM in the account + /// to cover transaction fees and reserve requirements. + Future cancelOrder({required String offerId}) async { + final offers = (await _sdk.offers.forAccount(accountId).execute()).records; + final OfferResponse targetOffer = offers.firstWhere( + (offer) => offer.id == offerId, + orElse: () => throw Exception( + 'Offer with ID $offerId not found in user\'s account.'), + ); + + final Asset sellingAsset = targetOffer.selling; + final Asset buyingAsset = targetOffer.buying; + + final ManageBuyOfferOperation cancelOfferOperation = + ManageBuyOfferOperationBuilder(sellingAsset, buyingAsset, '0', '1') + .setOfferId(offerId) + .build(); + + final account = await _sdk.accounts.account(accountId); + final Transaction transaction = + TransactionBuilder(account).addOperation(cancelOfferOperation).build(); + transaction.sign(_keyPair, _stellarNetwork); + try { + final SubmitTransactionResponse response = + await _sdk.submitTransaction(transaction); + if (!response.success) { + logger.e('Transaction failed with result: ${response.resultXdr}'); + return false; + } + return true; + } catch (error) { + throw Exception('Transaction failed due to: ${error.toString()}'); + } + } + + /// Updating a DEX order by submitting a `ManageBuyOfferOperation` transaction. + /// + /// This function allows user to update previously created order by its offerId. + /// + /// **Note:** Updating an order requires having XLM in the account + /// to cover transaction fees and reserve requirements. + /// + /// **Price Format:** + /// - The `price` should always include a leading zero for decimal values. + /// - For example, instead of writing `.1`, the price should be written as `0.1`. + /// - **Correct format**: `0.1` + /// - **Incorrect format**: `.1` + Future updateOrder( + {required String amount, + required String price, + required String offerId, + String? memo}) async { + final offers = (await _sdk.offers.forAccount(accountId).execute()).records; + final OfferResponse? targetOffer = offers.firstWhere( + (offer) => offer.id == offerId, + orElse: () => throw Exception( + 'Offer with ID $offerId not found in user\'s account.'), + ); + + ManageBuyOfferOperation updateOfferOperation = + ManageBuyOfferOperationBuilder( + targetOffer!.selling, + targetOffer.buying, + amount, + price, + ).setOfferId(offerId).build(); + + final account = await _sdk.accounts.account(accountId); + final Transaction transaction = + TransactionBuilder(account).addOperation(updateOfferOperation).build(); + transaction.sign(_keyPair, _stellarNetwork); + try { + final SubmitTransactionResponse response = + await _sdk.submitTransaction(transaction); + if (!response.success) { + logger.e('Transaction failed with result: ${response.resultXdr}'); + return false; + } + return true; + } catch (error) { + throw Exception('Transaction failed due to: ${error.toString()}'); + } + } + + /// Lists all active offers created by the current account. + /// + /// This function fetches a list of `OfferResponse` objects representing + /// open orders created by the account. + /// + /// ### Understanding Stellar Order Representation: + /// - **Price (`OfferResponse.price`)**: Stellar stores price as `buying / selling`, + /// meaning the displayed price is the **inverse** of the price provided + /// when creating an order. + /// - **Amount (`OfferResponse.amount`)**: This represents the amount of the + /// **buying asset** still available for trade, not the original amount + /// of the selling asset. + /// + /// ### Conversion Formula: + /// When placing an order: + /// ``` + /// Total selling amount = Buying amount * Price + /// ``` + /// + /// Stellar inverts the price when storing the offer: + /// ``` + /// Stored price = 1 / Provided price + /// ``` + /// + /// ### Example: + /// #### **Creating an Order** + /// ```dart + /// await stellarClient.createOrder( + /// sellingAssetCode: 'USDC', + /// buyingAssetCode: 'TFT', + /// amount: '5', // Buying 5 TFT + /// price: '0.02'); // 1 USDC = 0.02 TFT + /// ``` + /// + /// #### **Retrieved Offer (from Stellar Order Book)** + /// ```dart + /// OfferResponse { + /// amount: "0.2", // Total selling amount = 5 * 0.02 = 0.2 USDC + /// price: "50.0" // Inverted: 1 / 0.02 = 50 USDC per TFT + /// } + /// ``` + /// + /// **Key Takeaways:** + /// - `OfferResponse.amount` = **Total amount of the selling asset left**. + /// - `OfferResponse.price` = **Inverse of the provided price**. + Future> listMyOffers() async { + try { + final offers = await _sdk.offers.forAccount(accountId).execute(); + + if (offers.records.isEmpty) { + logger.i('No offers found for account: $accountId'); + return []; + } + + return offers.records; + } catch (error) { + throw Exception('Error listing offers for account $accountId: $error'); + } + } } diff --git a/packages/stellar_client/lib/src/helpers.dart b/packages/stellar_client/lib/src/helpers.dart index 26ba567..45f93b2 100644 --- a/packages/stellar_client/lib/src/helpers.dart +++ b/packages/stellar_client/lib/src/helpers.dart @@ -31,3 +31,96 @@ Future> getBalanceByAccountID( return balancesList; } + +Future> getTradingHistory( + {required NetworkType network, required String accountId}) async { + try { + late StellarSDK _sdk; + List allTrades = []; + + switch (network) { + case NetworkType.TESTNET: + _sdk = StellarSDK.TESTNET; + break; + case NetworkType.PUBLIC: + _sdk = StellarSDK.PUBLIC; + break; + } + + Page? tradesPage = + await _sdk.trades.forAccount(accountId).execute(); + final httpClient = http.Client(); + try { + while (tradesPage != null) { + allTrades.addAll(tradesPage.records); + tradesPage = await tradesPage.getNextPage(httpClient); + if (tradesPage == null || tradesPage.records.isEmpty) { + break; + } + } + } finally { + httpClient.close(); + } + return allTrades; + } catch (e) { + throw Exception('Failed to fetch trading history: ${e.toString()}'); + } +} + +/// Retrieves the order book for a given asset pair on the Stellar network. +/// +/// This function returns a stream of `OrderBookResponse`, which provides +/// real-time updates on buy and sell orders for the specified asset pair. +/// +/// ### Understanding Stellar Order Representation: +/// - **Price (`OrderBookResponse.asks[].price` & `OrderBookResponse.bids[].price`)**: +/// Stellar stores price as `buying / selling`, meaning the displayed price +/// is the **inverse** of the price provided when creating an order. +/// - **Amount (`OrderBookResponse.asks[].amount`)**: +/// This represents the total amount of the **selling asset** available in the order book. +/// +/// ### Conversion Formula: +/// ``` +/// Total selling amount = Buying amount * Price +/// Stored price = 1 / Provided price +/// ``` +/// +/// ### Example: +/// #### **Creating an Order** +/// ```dart +/// await stellarClient.createOrder( +/// sellingAssetCode: 'XLM', +/// buyingAssetCode: 'TFT', +/// amount: '2', // Buying 2 TFT +/// price: '0.1'); // 1 XLM = 0.1 TFT +/// ``` +/// +/// #### **Retrieved Order Book Entry** +/// ```dart +/// OrderBookResponse { +/// asks: [ +/// { +/// amount: "0.2", // Total selling amount = 2 * 0.1 = 0.2 XLM +/// price: "10.0" // Inverted: 1 / 0.1 = 10 XLM per TFT +/// } +/// ] +/// } +/// ``` +/// +/// **Key Takeaways:** +/// - `OrderBookResponse.asks[].amount` = **Total amount of the selling asset**. +/// - `OrderBookResponse.asks[].price` = **Inverse of the provided price**. +Future> getOrderBook( + {required String horizonUrl, + required Asset sellingAsset, + required Asset buyingAsset}) async { + http.Client httpClient = http.Client(); + Uri serverURI = Uri.parse(horizonUrl); + + OrderBookRequestBuilder orderBookRequest = + OrderBookRequestBuilder(httpClient, serverURI) + ..sellingAsset(sellingAsset) + ..buyingAsset(buyingAsset); + + return await orderBookRequest.stream(); +} diff --git a/packages/stellar_client/pubspec.lock b/packages/stellar_client/pubspec.lock index 455155d..e5693c2 100644 --- a/packages/stellar_client/pubspec.lock +++ b/packages/stellar_client/pubspec.lock @@ -159,10 +159,10 @@ packages: dependency: "direct main" description: name: http - sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.3.0" http_multi_server: dependency: transitive description: diff --git a/packages/stellar_client/pubspec.yaml b/packages/stellar_client/pubspec.yaml index 0d6f967..710db4c 100644 --- a/packages/stellar_client/pubspec.yaml +++ b/packages/stellar_client/pubspec.yaml @@ -7,7 +7,7 @@ environment: # Add regular dependencies here. dependencies: - http: ^1.2.2 + http: 1.3.0 stellar_flutter_sdk: ^1.9.2 dev_dependencies: