diff --git a/packages/stellar_client/lib/src/client.dart b/packages/stellar_client/lib/src/client.dart index ed5606c..f00759d 100644 --- a/packages/stellar_client/lib/src/client.dart +++ b/packages/stellar_client/lib/src/client.dart @@ -6,6 +6,7 @@ class Client { late KeyPair _keyPair; late currency.Currencies _currencies; late Map _serviceUrls; + late Map _horizonServerUrls; late Network _stellarNetwork; String get accountId => _keyPair.accountId; @@ -49,10 +50,16 @@ 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' }; + _horizonServerUrls = { + 'PUBLIC': 'https://horizon.stellar.org/', + 'TESTNET': 'https://horizon-testnet.stellar.org/' + }; + switch (_network) { case NetworkType.TESTNET: _sdk = StellarSDK.TESTNET; @@ -61,6 +68,9 @@ class Client { assetCode: 'TFT', issuer: "GA47YZA3PKFUZMPLQ3B5F2E3CJIB57TGGU7SPCQT2WAEYKN766PWIMB3", ); + usdc = currency.Currency( + assetCode: 'USDC', + issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); break; case NetworkType.PUBLIC: _sdk = StellarSDK.PUBLIC; @@ -69,10 +79,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 +147,29 @@ class Client { } } + /// 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 addTrustLine() 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 +190,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 +276,6 @@ class Client { ); final data = jsonDecode(response.body); - String trustlineTransaction = data['addtrustline_transaction']; XdrTransactionEnvelope xdrTxEnvelope = XdrTransactionEnvelope.fromEnvelopeXdrString(trustlineTransaction); @@ -518,4 +562,350 @@ 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 ManageBuyOfferOperation buyOfferOperation = + ManageBuyOfferOperationBuilder(sellingAsset, buyingAsset, amount, price) + .build(); + + final account = await _sdk.accounts.account(accountId); + final balances = account.balances; + + try { + final sellAssetBalance = balances.firstWhere( + (balance) { + if (sellingAssetCode == 'XLM' && balance.assetCode == null) { + // Special case for XLM + return true; + } else { + return balance.assetCode == sellingAssetCode; + } + }, + orElse: () { + logger.e("Sell asset $sellingAssetCode not found in balances."); + throw Exception('Insufficient balance in $sellingAssetCode'); + }, + ); + + 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(buyOfferOperation) + .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 sellingAssetCode, + required String buyingAssetCode, + required String offerId, + 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 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 = _getAsset(sellingAssetCode); + final Asset buyingAsset = _getAsset(buyingAssetCode); + + 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) + .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()}'); + } + } + + /// 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 sellingAssetCode, + required String buyingAssetCode, + required String amount, + required String price, + required String offerId, + 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 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 = _getAsset(sellingAssetCode); + final Asset buyingAsset = _getAsset(buyingAssetCode); + + ManageBuyOfferOperation updateOfferOperation = + ManageBuyOfferOperationBuilder( + sellingAsset, + buyingAsset, + 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()}'); + } + } + + /// 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 sellingAssetCode, + required String buyingAssetCode}) 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.'); + } + http.Client httpClient = http.Client(); + Uri serverURI = Uri.parse(_horizonServerUrls[_network.toString()]!); + + final Asset sellingAsset = _getAsset(sellingAssetCode); + final Asset buyingAsset = _getAsset(buyingAssetCode); + + OrderBookRequestBuilder orderBookRequest = + OrderBookRequestBuilder(httpClient, serverURI) + ..sellingAsset(sellingAsset) + ..buyingAsset(buyingAsset); + + return await orderBookRequest.stream(); + } + + /// 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'); + } + } + + Future> getTradingHistory(String accountId) async { + try { + Page tradesPage = + await _sdk.trades.forAccount(accountId).execute(); + + return tradesPage.records; + } catch (e) { + throw Exception('Failed to fetch trading history: ${e.toString()}'); + } + } } 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: