Skip to content

Commit 0e348a4

Browse files
authored
Support DEX orders on stellar client (#119)
* WIP: support creating && cancelling orders * support creating && cancelling orders * WIP: trying to create orders with TF service * support native asset && comment orders with TF service * remova commented code * fix workflow * added native asset in currencies list && remove all prints * WIP: fix logger && trustlines issue * fix balance checking issue && update renaming * support updating an offer * upgrade http version * added documentation && removed redundant code for getting asset * update docs * added function for getting trading history * update return of create, cancel and update orders to be bool * fix bug in create order * apply pr comments && refactor code * fix workflow * handle 2 loops in creating order && remove memo from cancel order
1 parent 497db36 commit 0e348a4

4 files changed

Lines changed: 408 additions & 11 deletions

File tree

packages/stellar_client/lib/src/client.dart

Lines changed: 312 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,12 @@ class Client {
4949

5050
void _initialize() {
5151
late final currency.Currency tft;
52+
late final currency.Currency usdc;
5253
_serviceUrls = {
5354
'PUBLIC': 'https://tokenservices.threefold.io/threefoldfoundation',
5455
'TESTNET': 'https://testnet.threefold.io/threefoldfoundation'
5556
};
57+
5658
switch (_network) {
5759
case NetworkType.TESTNET:
5860
_sdk = StellarSDK.TESTNET;
@@ -61,6 +63,9 @@ class Client {
6163
assetCode: 'TFT',
6264
issuer: "GA47YZA3PKFUZMPLQ3B5F2E3CJIB57TGGU7SPCQT2WAEYKN766PWIMB3",
6365
);
66+
usdc = currency.Currency(
67+
assetCode: 'USDC',
68+
issuer: 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5');
6469
break;
6570
case NetworkType.PUBLIC:
6671
_sdk = StellarSDK.PUBLIC;
@@ -69,10 +74,17 @@ class Client {
6974
assetCode: 'TFT',
7075
issuer: "GBOVQKJYHXRR3DX6NOX2RRYFRCUMSADGDESTDNBDS6CDVLGVESRTAC47",
7176
);
77+
usdc = currency.Currency(
78+
assetCode: 'USDC',
79+
issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN');
7280
break;
7381
}
7482

75-
_currencies = currency.Currencies({'TFT': tft});
83+
_currencies = currency.Currencies({
84+
'TFT': tft,
85+
'USDC': usdc,
86+
'XLM': currency.Currency(assetCode: 'XLM', issuer: "")
87+
});
7688
}
7789

7890
Future<bool> activateThroughThreefoldService() async {
@@ -130,10 +142,29 @@ class Client {
130142
}
131143
}
132144

133-
Future<bool> addTrustLine() async {
145+
/// Adds trustline for all non-native assets in the `_currencies.currencies` map.
146+
///
147+
/// Trustlines are required to hold non-native assets on a Stellar account.
148+
/// This function iterates over all available currencies and attempts to
149+
/// establish trustlines for each, except for the native asset (`XLM`).
150+
///
151+
/// **Note:** Adding trustline requires having XLM in the account
152+
///
153+
/// ### Returns:
154+
/// - `true` if all trustlines were successfully added.
155+
/// - `false` if one or more trustlines failed.
156+
Future<bool> addConfiguredTrustlines() async {
157+
bool allTrustlinesAdded = true;
158+
134159
for (var entry in _currencies.currencies.entries) {
135160
String currencyCode = entry.key;
136161
currency.Currency currentCurrency = entry.value;
162+
if (currencyCode == 'XLM') {
163+
logger.i("Skipping trustline for native asset $currencyCode");
164+
continue;
165+
}
166+
logger.i(
167+
"Processing trustline for ${entry.key} with issuer ${entry.value.issuer}");
137168

138169
String issuerAccountId = currentCurrency.issuer;
139170
Asset currencyAsset =
@@ -154,17 +185,26 @@ class Client {
154185

155186
if (!response.success) {
156187
logger.e("Failed to add trustline for $currencyCode");
157-
return false;
188+
allTrustlinesAdded = false;
158189
} else {
159-
logger.i("trustline for $currencyCode was added successfully");
160-
return true;
190+
logger.i("Trustline for $currencyCode was added successfully");
161191
}
162192
}
163193

164-
logger.i("No trustlines were processed");
165-
return false;
194+
if (allTrustlinesAdded) {
195+
logger.i("All trustlines were added successfully");
196+
return true;
197+
} else {
198+
logger.e("One or more trustlines failed to be added");
199+
return false;
200+
}
166201
}
167202

203+
/// Transfers a specified amount of currency to a destination address.
204+
///
205+
/// This function builds a Stellar transaction to send funds from the current account
206+
/// to a given recipient. It supports optional memo fields for additional transaction details.
207+
/// **Note:** Transfer requires having XLM in the account
168208
Future<bool> transfer(
169209
{required String destinationAddress,
170210
required String amount,
@@ -231,7 +271,6 @@ class Client {
231271
);
232272

233273
final data = jsonDecode(response.body);
234-
235274
String trustlineTransaction = data['addtrustline_transaction'];
236275
XdrTransactionEnvelope xdrTxEnvelope =
237276
XdrTransactionEnvelope.fromEnvelopeXdrString(trustlineTransaction);
@@ -519,4 +558,269 @@ class Client {
519558
throw Exception("Couldn't get memo text due to ${e}");
520559
}
521560
}
561+
562+
Asset _getAsset(String assetCode) {
563+
if (assetCode == 'XLM') {
564+
return AssetTypeNative();
565+
}
566+
567+
final asset = _currencies.currencies[assetCode];
568+
if (asset == null) {
569+
throw Exception('Asset $assetCode is not available');
570+
}
571+
572+
return AssetTypeCreditAlphaNum4(asset.assetCode, asset.issuer);
573+
}
574+
575+
/// Creates a DEX order by submitting a `ManageBuyOfferOperation` transaction.
576+
///
577+
/// This function allows user to create an order to buy a specified asset
578+
/// using another asset on Stellar network.
579+
///
580+
/// **Note:** Creating an order requires having XLM in the account
581+
/// to cover transaction fees and reserve requirements.
582+
///
583+
/// **Price Format:**
584+
/// - The `price` should always include a leading zero for decimal values.
585+
/// - For example, instead of writing `.1`, the price should be written as `0.1`.
586+
/// - **Correct format**: `0.1`
587+
/// - **Incorrect format**: `.1`
588+
Future<bool> createOrder({
589+
required String sellingAssetCode,
590+
required String buyingAssetCode,
591+
required String amount,
592+
required String price,
593+
String? memo,
594+
}) async {
595+
if (!_currencies.currencies.containsKey(sellingAssetCode)) {
596+
throw Exception('Sell asset $sellingAssetCode is not available.');
597+
}
598+
if (!_currencies.currencies.containsKey(buyingAssetCode)) {
599+
throw Exception('Buy asset $buyingAssetCode is not available.');
600+
}
601+
602+
final Asset sellingAsset = _getAsset(sellingAssetCode);
603+
final Asset buyingAsset = _getAsset(buyingAssetCode);
604+
605+
final ManageSellOfferOperation sellOfferOperation =
606+
ManageSellOfferOperationBuilder(
607+
sellingAsset, buyingAsset, amount, price)
608+
.build();
609+
610+
final account = await _sdk.accounts.account(accountId);
611+
final balances = account.balances;
612+
613+
try {
614+
Balance? sellAssetBalance;
615+
Balance? buyAssetBalance;
616+
617+
for (final balance in balances) {
618+
if (sellAssetBalance == null) {
619+
if (sellingAssetCode == 'XLM' && balance.assetCode == null) {
620+
sellAssetBalance = balance;
621+
} else if (balance.assetCode == sellingAssetCode) {
622+
sellAssetBalance = balance;
623+
}
624+
}
625+
626+
if (buyingAssetCode != 'XLM' && buyAssetBalance == null) {
627+
if (balance.assetCode == buyingAssetCode) {
628+
buyAssetBalance = balance;
629+
}
630+
}
631+
632+
if (sellAssetBalance != null &&
633+
(buyingAssetCode == 'XLM' || buyAssetBalance != null)) {
634+
break;
635+
}
636+
}
637+
638+
if (sellAssetBalance == null) {
639+
logger.e("Sell asset $sellingAssetCode not found in balances.");
640+
throw Exception('Insufficient balance in $sellingAssetCode');
641+
}
642+
643+
if (buyingAssetCode != 'XLM' && buyAssetBalance == null) {
644+
logger.e("Buy asset $buyingAssetCode not found in balances.");
645+
throw Exception('No trustline for $buyingAssetCode');
646+
}
647+
648+
final double sellAmount = double.parse(amount);
649+
final double availableBalance = double.parse(sellAssetBalance.balance);
650+
651+
if (sellAmount > availableBalance) {
652+
throw Exception(
653+
'Insufficient balance in $sellingAssetCode. Available: $availableBalance');
654+
}
655+
} catch (e) {
656+
logger.e("Error: ${e.toString()}");
657+
rethrow;
658+
}
659+
660+
final Transaction transaction = TransactionBuilder(account)
661+
.addOperation(sellOfferOperation)
662+
.addMemo(memo != null ? Memo.text(memo) : Memo.none())
663+
.build();
664+
665+
transaction.sign(_keyPair, _stellarNetwork);
666+
try {
667+
final SubmitTransactionResponse response =
668+
await _sdk.submitTransaction(transaction);
669+
if (!response.success) {
670+
logger.e('Transaction failed with result: ${response.resultXdr}');
671+
return false;
672+
}
673+
return true;
674+
} catch (error) {
675+
throw Exception('Transaction failed due to: ${error.toString()}');
676+
}
677+
}
678+
679+
/// Cancels a DEX order by submitting a `ManageBuyOfferOperation` transaction with zero amount.
680+
///
681+
/// This function allows user to cancel previously created order with its offerId.
682+
///
683+
/// **Note:** Cancelling an order requires having XLM in the account
684+
/// to cover transaction fees and reserve requirements.
685+
Future<bool> cancelOrder({required String offerId}) async {
686+
final offers = (await _sdk.offers.forAccount(accountId).execute()).records;
687+
final OfferResponse targetOffer = offers.firstWhere(
688+
(offer) => offer.id == offerId,
689+
orElse: () => throw Exception(
690+
'Offer with ID $offerId not found in user\'s account.'),
691+
);
692+
693+
final Asset sellingAsset = targetOffer.selling;
694+
final Asset buyingAsset = targetOffer.buying;
695+
696+
final ManageBuyOfferOperation cancelOfferOperation =
697+
ManageBuyOfferOperationBuilder(sellingAsset, buyingAsset, '0', '1')
698+
.setOfferId(offerId)
699+
.build();
700+
701+
final account = await _sdk.accounts.account(accountId);
702+
final Transaction transaction =
703+
TransactionBuilder(account).addOperation(cancelOfferOperation).build();
704+
transaction.sign(_keyPair, _stellarNetwork);
705+
try {
706+
final SubmitTransactionResponse response =
707+
await _sdk.submitTransaction(transaction);
708+
if (!response.success) {
709+
logger.e('Transaction failed with result: ${response.resultXdr}');
710+
return false;
711+
}
712+
return true;
713+
} catch (error) {
714+
throw Exception('Transaction failed due to: ${error.toString()}');
715+
}
716+
}
717+
718+
/// Updating a DEX order by submitting a `ManageBuyOfferOperation` transaction.
719+
///
720+
/// This function allows user to update previously created order by its offerId.
721+
///
722+
/// **Note:** Updating an order requires having XLM in the account
723+
/// to cover transaction fees and reserve requirements.
724+
///
725+
/// **Price Format:**
726+
/// - The `price` should always include a leading zero for decimal values.
727+
/// - For example, instead of writing `.1`, the price should be written as `0.1`.
728+
/// - **Correct format**: `0.1`
729+
/// - **Incorrect format**: `.1`
730+
Future<bool> updateOrder(
731+
{required String amount,
732+
required String price,
733+
required String offerId,
734+
String? memo}) async {
735+
final offers = (await _sdk.offers.forAccount(accountId).execute()).records;
736+
final OfferResponse? targetOffer = offers.firstWhere(
737+
(offer) => offer.id == offerId,
738+
orElse: () => throw Exception(
739+
'Offer with ID $offerId not found in user\'s account.'),
740+
);
741+
742+
ManageBuyOfferOperation updateOfferOperation =
743+
ManageBuyOfferOperationBuilder(
744+
targetOffer!.selling,
745+
targetOffer.buying,
746+
amount,
747+
price,
748+
).setOfferId(offerId).build();
749+
750+
final account = await _sdk.accounts.account(accountId);
751+
final Transaction transaction =
752+
TransactionBuilder(account).addOperation(updateOfferOperation).build();
753+
transaction.sign(_keyPair, _stellarNetwork);
754+
try {
755+
final SubmitTransactionResponse response =
756+
await _sdk.submitTransaction(transaction);
757+
if (!response.success) {
758+
logger.e('Transaction failed with result: ${response.resultXdr}');
759+
return false;
760+
}
761+
return true;
762+
} catch (error) {
763+
throw Exception('Transaction failed due to: ${error.toString()}');
764+
}
765+
}
766+
767+
/// Lists all active offers created by the current account.
768+
///
769+
/// This function fetches a list of `OfferResponse` objects representing
770+
/// open orders created by the account.
771+
///
772+
/// ### Understanding Stellar Order Representation:
773+
/// - **Price (`OfferResponse.price`)**: Stellar stores price as `buying / selling`,
774+
/// meaning the displayed price is the **inverse** of the price provided
775+
/// when creating an order.
776+
/// - **Amount (`OfferResponse.amount`)**: This represents the amount of the
777+
/// **buying asset** still available for trade, not the original amount
778+
/// of the selling asset.
779+
///
780+
/// ### Conversion Formula:
781+
/// When placing an order:
782+
/// ```
783+
/// Total selling amount = Buying amount * Price
784+
/// ```
785+
///
786+
/// Stellar inverts the price when storing the offer:
787+
/// ```
788+
/// Stored price = 1 / Provided price
789+
/// ```
790+
///
791+
/// ### Example:
792+
/// #### **Creating an Order**
793+
/// ```dart
794+
/// await stellarClient.createOrder(
795+
/// sellingAssetCode: 'USDC',
796+
/// buyingAssetCode: 'TFT',
797+
/// amount: '5', // Buying 5 TFT
798+
/// price: '0.02'); // 1 USDC = 0.02 TFT
799+
/// ```
800+
///
801+
/// #### **Retrieved Offer (from Stellar Order Book)**
802+
/// ```dart
803+
/// OfferResponse {
804+
/// amount: "0.2", // Total selling amount = 5 * 0.02 = 0.2 USDC
805+
/// price: "50.0" // Inverted: 1 / 0.02 = 50 USDC per TFT
806+
/// }
807+
/// ```
808+
///
809+
/// **Key Takeaways:**
810+
/// - `OfferResponse.amount` = **Total amount of the selling asset left**.
811+
/// - `OfferResponse.price` = **Inverse of the provided price**.
812+
Future<List<OfferResponse>> listMyOffers() async {
813+
try {
814+
final offers = await _sdk.offers.forAccount(accountId).execute();
815+
816+
if (offers.records.isEmpty) {
817+
logger.i('No offers found for account: $accountId');
818+
return [];
819+
}
820+
821+
return offers.records;
822+
} catch (error) {
823+
throw Exception('Error listing offers for account $accountId: $error');
824+
}
825+
}
522826
}

0 commit comments

Comments
 (0)