@stellar-broker/client 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ import {Asset, Keypair, Memo, Operation, TransactionBuilder, Horizon, Networks, StrKey} from '@stellar/stellar-sdk'
2
+ import {fromStroops, toStroops} from './stroops.js'
3
+ import {convertToStellarAsset} from './asset.js'
4
+ import {AuthorizationWrapper} from './authorization.js'
5
+
6
+ //additional XLM amount to cover tx fees
7
+ const feesReserve = '2'
8
+
9
+ export class Mediator {
10
+ /**
11
+ * Create a trader mediator account instance for a given source account
12
+ * @param {string} source - Creator account address
13
+ * @param {string|Asset} sellingAsset - Identifier of the asset to sell
14
+ * @param {string|Asset} buyingAsset - Identifier of the asset to buy
15
+ * @param {string} sellingAmount - Asset amount to sell
16
+ * @param {ClientAuthorizationParams} authorization - Authorization
17
+ */
18
+ constructor(source, sellingAsset, buyingAsset, sellingAmount, authorization) {
19
+ if (!StrKey.isValidEd25519PublicKey(source))
20
+ throw new Error('Invalid source account')
21
+ this.source = source
22
+ try {
23
+ this.sellingAsset = convertToStellarAsset(sellingAsset)
24
+ } catch (e) {
25
+ console.error(e)
26
+ throw new Error('Invalid selling asset')
27
+ }
28
+ try {
29
+ this.buyingAsset = convertToStellarAsset(buyingAsset)
30
+ } catch (e) {
31
+ console.error(e)
32
+ throw new Error('Invalid buying asset')
33
+ }
34
+ try {
35
+ this.sellingAmount = toStroops(sellingAmount)
36
+ } catch (e) {
37
+ console.error(e)
38
+ throw new Error('Invalid selling amount')
39
+ }
40
+ this.authorization = new AuthorizationWrapper(authorization)
41
+ }
42
+
43
+ /**
44
+ * Creator account address
45
+ * @type {string}
46
+ * @readonly
47
+ */
48
+ source
49
+ /**
50
+ * @type {Asset}
51
+ * @readonly
52
+ */
53
+ sellingAsset
54
+ /**
55
+ * @type {Asset}
56
+ * @readonly
57
+ */
58
+ buyingAsset
59
+ /**
60
+ * @type {bigint}
61
+ * @readonly
62
+ */
63
+ sellingAmount
64
+ /**
65
+ * @type {AuthorizationWrapper}
66
+ * @private
67
+ */
68
+ authorization
69
+ /**
70
+ * @type {Keypair}
71
+ * @private
72
+ */
73
+ mediator
74
+ /**
75
+ * @type {string}
76
+ * @readonly
77
+ */
78
+ mediatorAddress
79
+ /**
80
+ * Prefix of the mediator address record in the localStorage
81
+ * @type {string}
82
+ * @private
83
+ */
84
+ storagePrefix = 'msb_'
85
+
86
+ /**
87
+ * Check if there are any non-disposed mediators that belong to lost swap sessions
88
+ * @return {boolean}
89
+ */
90
+ get hasObsoleteMediators() {
91
+ return Object.entries(localStorage).some(([key, initiator]) =>
92
+ initiator === this.source && key.startsWith(this.storagePrefix))
93
+ }
94
+
95
+ /**
96
+ * Retrieve funds from mediator accounts that belong to lost swap sessions
97
+ * @return {Promise}
98
+ */
99
+ async disposeObsoleteMediators() { //TODO: deal with potential problems caused by concurrent swaps
100
+ for (let [key, initiator] of Object.entries(localStorage)) {
101
+ if (initiator === this.source && key.startsWith(this.storagePrefix)) {
102
+ await this.dispose(key.replace(this.storagePrefix, ''))
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Create mediator account and deposit tokens to sell
109
+ * @return {Promise<string>} - Newly created mediator secret key
110
+ */
111
+ async init() {
112
+ const sourceAccount = await this.loadAccount(this.source)
113
+ if (!sourceAccount)
114
+ throw new Error('Mediator account doesn\'t exist on the ledger')
115
+ const ops = []
116
+ //create new random keypair for the trade
117
+ this.mediator = Keypair.random()
118
+ this.mediatorAddress = this.mediator.publicKey()
119
+ const {sellingAsset, buyingAsset} = this
120
+ if (sellingAsset.isNative()) { //for XLM total amount should include fee reserves
121
+ const amount = this.sellingAmount + BigInt(feesReserve * 10000000)
122
+ //check available balance
123
+ if (toStroops(findTrustline(sourceAccount, sellingAsset).balance) < amount)
124
+ throw new Error('Insufficient XLM balance for selling amount + potential trading fees')
125
+ //only create account is required for asset transfer
126
+ ops.push(Operation.createAccount({
127
+ source: this.source,
128
+ destination: this.mediatorAddress,
129
+ startingBalance: fromStroops(amount)
130
+ }))
131
+ } else {
132
+ //check available XLM balance
133
+ const xlmBalance = findTrustline(sourceAccount, Asset.native())
134
+ if (xlmBalance.balance < parseInt(feesReserve))
135
+ throw new Error('Insufficient XLM balance for potential trading fees')
136
+ //create mediator account
137
+ ops.push(Operation.createAccount({
138
+ source: this.source,
139
+ destination: this.mediatorAddress,
140
+ startingBalance: feesReserve // for tx fees
141
+ }))
142
+ //check available XLM balance
143
+ const sellingTrustline = findTrustline(sourceAccount, this.sellingAsset)
144
+ if (!sellingTrustline || toStroops(sellingTrustline.balance) < this.sellingAmount)
145
+ throw new Error('Insufficient selling asset balance')
146
+ if (!findTrustline(sourceAccount, this.buyingAsset))
147
+ throw new Error('Source account doesn\'t have buying asset trustline')
148
+ //TODO: check authorizations
149
+ //create trustline for selling asset
150
+ ops.push(Operation.changeTrust({
151
+ source: this.mediatorAddress,
152
+ asset: sellingAsset
153
+ }))
154
+ //transfer tokens to sell
155
+ ops.push(Operation.payment({
156
+ source: this.source,
157
+ asset: sellingAsset,
158
+ destination: this.mediatorAddress,
159
+ amount: fromStroops(this.sellingAmount)
160
+ }))
161
+ }
162
+ //add source account as a signer
163
+ ops.push(Operation.setOptions({
164
+ source: this.mediatorAddress,
165
+ homeDomain: 'mediator.stellar.broker',
166
+ masterWeight: 1,
167
+ highThreshold: 1,
168
+ medThreshold: 1,
169
+ lowThreshold: 1,
170
+ signer: {
171
+ ed25519PublicKey: this.source,
172
+ weight: 1
173
+ }
174
+ }))
175
+ //create trustline for buying asset
176
+ ops.push(Operation.changeTrust({
177
+ source: this.mediatorAddress,
178
+ asset: buyingAsset
179
+ }))
180
+ //confirm tx
181
+ await this.buildAndSend(sourceAccount, ops)
182
+ //store the record in the localStorage
183
+ localStorage.setItem(this.storagePrefix + this.mediatorAddress, this.source)
184
+ //client will need mediator secret for trading
185
+ return this.mediator.secret()
186
+ }
187
+
188
+ /**
189
+ * Dispose mediator account
190
+ * @param {string} [address]
191
+ * @return {Promise}
192
+ */
193
+ async dispose(address) {
194
+ if (!address) {
195
+ address = this.mediatorAddress
196
+ }
197
+ //load account
198
+ let account = await this.loadAccount(address)
199
+ if (!account)
200
+ throw new Error(`Mediator account ${address} doesn't exist on the ledger`)
201
+
202
+ if (!account.signers.find(s => s.key === this.source))
203
+ throw new Error(`${address} is not a mediator account for ${this.source}`)
204
+ const ops = []
205
+ //remove trustlines for each account balance
206
+ for (const balance of account.balances) {
207
+ if (balance.asset_type === 'native')
208
+ continue //skip XLM trustline - merge will handle the transfer
209
+ const asset = convertToStellarAsset(balance)
210
+ //transfer remaining balance to the source account
211
+ if (balance.balance > 0) {
212
+ ops.push(Operation.payment({
213
+ source: address,
214
+ asset,
215
+ destination: this.source,
216
+ amount: balance.balance
217
+ }))
218
+ }
219
+ //remove trustline
220
+ ops.push(Operation.changeTrust({
221
+ source: address,
222
+ asset,
223
+ limit: '0'
224
+ }))
225
+ }
226
+ //merge
227
+ ops.push(Operation.accountMerge({
228
+ source: address,
229
+ destination: this.source
230
+ }))
231
+ if (this.mediatorAddress !== address) {
232
+ account = await this.loadAccount(this.source)
233
+ }
234
+ await this.buildAndSend(account, ops)
235
+ //remove reference from local storage
236
+ localStorage.removeItem(this.storagePrefix + address)
237
+ }
238
+
239
+ /**
240
+ * @param {AccountResponse} account
241
+ * @param {Operation[]} operations
242
+ * @param {string} [memo]
243
+ * @private
244
+ */
245
+ async buildAndSend(account, operations, memo) {
246
+ //create builder
247
+ const builder = new TransactionBuilder(account, {
248
+ fee: '1000000',
249
+ networkPassphrase: Networks.PUBLIC
250
+ })
251
+ builder.setTimeout(30)
252
+ //add memo if needed
253
+ if (memo) {
254
+ builder.addMemo(Memo.text(memo))
255
+ }
256
+ //add operations
257
+ for (const op of operations) {
258
+ builder.addOperation(op)
259
+ }
260
+ //build tx
261
+ let tx = builder.build()
262
+ //sign it on behalf of the mediator account (if available)
263
+ if (this.mediator) {
264
+ tx.sign(this.mediator)
265
+ }
266
+ //request signature from the client (if required)
267
+ if (account.account_id === this.source) {
268
+ tx = await this.authorization.authorize(tx)
269
+ }
270
+ //execute the tx
271
+ const res = await this.createHorizon().submitTransaction(tx, {skipMemoRequiredCheck: true})
272
+ if (!res.successful)
273
+ throw new Error('Failed to create mediator account')
274
+ }
275
+
276
+ /**
277
+ * @param {string} address
278
+ * @return {Promise<AccountResponse>}
279
+ * @private
280
+ */
281
+ async loadAccount(address) {
282
+ try {
283
+ return this.createHorizon().loadAccount(address)
284
+ } catch (e) {
285
+ console.error(e)
286
+ }
287
+ }
288
+
289
+ /**
290
+ * @return {HorizonServer}
291
+ * @private
292
+ */
293
+ createHorizon() {
294
+ return new Horizon.Server('https://horizon.stellar.org')
295
+ }
296
+ }
297
+
298
+
299
+ /**
300
+ * @param {AccountResponse} account
301
+ * @param {Asset} asset
302
+ * @return {BalanceLine}
303
+ */
304
+ function findTrustline(account, asset) {
305
+ return account.balances.find(b => asset.isNative() ?
306
+ b.asset_type === 'native' :
307
+ b.asset_code === asset.code && b.asset_issuer === asset.issuer
308
+ )
309
+ }
@@ -1,61 +1,57 @@
1
- import {StrKey} from '@stellar/stellar-sdk'
2
- import errors from './errors.js'
3
- import {parseAsset} from './asset.js'
4
-
5
- /**
6
- * @typedef {object} SwapQuoteParams
7
- * @property {string} sellingAsset - Asset to sell
8
- * @property {string} buyingAsset - Asset to buy
9
- * @property {string} [sellingAmount] - Amount of selling asset
10
- * @property {string} [buyingAmount] - Amount of buying asset
11
- * @property {number} [slippageTolerance] - Swap slippage tolerance (0.02 by default - 2%)
12
- * @property {string} [destination] - Trader account address
13
- */
14
-
15
- /**
16
- * @param {SwapQuoteParams} params
17
- * @return {SwapQuoteParams}
18
- */
19
- export function validateQuoteRequest(params) {
20
- const {
21
- destination, sellingAsset, selling_asset, buyingAsset, buying_asset, sellingAmount, selling_amount,
22
- buyingAmount, buying_amount, slippageTolerance, slippage_tolerance, ...other
23
- } = params
24
- const res = {
25
- sellingAsset: parseAsset(sellingAsset || selling_asset, 'sellingAsset'),
26
- buyingAsset: parseAsset(buyingAsset || buying_asset, 'buyingAsset'),
27
- sellingAmount: parseAmount(sellingAmount || selling_amount, 'sellingAmount'),
28
- buyingAmount: parseAmount(buyingAmount || buying_amount, 'buyingAmount'),
29
- slippageTolerance: parseSlippageTolerance(slippageTolerance || slippage_tolerance || 0.02, 'slippageTolerance'),
30
- flow: 'direct'
31
- }
32
- if (res.buyingAsset === res.sellingAsset)
33
- throw errors.invalidQuoteParam('buyingAsset', 'Buying asset can\'t be the same as selling asset')
34
- if (res.buyingAmount === undefined && res.sellingAmount === undefined)
35
- throw errors.invalidQuoteParam('sellingAmount', 'Either "buyingAmount" or "sellingAmount" parameter is required')
36
- if (res.buyingAmount !== undefined && res.sellingAmount !== undefined)
37
- throw errors.invalidQuoteParam('sellingAmount', 'Parameters "buyingAmount" and "sellingAmount" are mutually exclusive')
38
- Object.assign(res, other) //add remaining optional params
39
- Object.freeze(res)
40
- return res
41
- }
42
-
43
- function parseAmount(amount, parameter) {
44
- if (amount === undefined)
45
- return undefined
46
- const parsed = parseFloat(amount)
47
- if (isNaN(parsed) || parsed <= 0)
48
- throw errors.invalidQuoteParam(parameter, 'Invalid asset amount: ' + amount)
49
- return amount
50
- }
51
-
52
- function parseSlippageTolerance(src, parameter) {
53
- const tolerance = parseFloat(src)
54
- if (isNaN(tolerance))
55
- throw errors.invalidQuoteParam(parameter, 'Invalid slippage tolerance, number expected')
56
- if (tolerance < 0)
57
- throw errors.invalidQuoteParam(parameter, 'Slippage tolerance is too small, expected value >= 0')
58
- if (tolerance > 0.5)
59
- throw errors.invalidQuoteParam(parameter, 'Slippage tolerance is too large, expected value < 0.5 (50%)')
60
- return tolerance
1
+ import errors from './errors.js'
2
+ import {parseAsset} from './asset.js'
3
+
4
+ /**
5
+ * @typedef {object} QuoteParams - Quote request parameters provided by the client
6
+ * @property {string} sellingAsset - Asset to sell
7
+ * @property {string} buyingAsset - Asset to buy
8
+ * @property {string} [sellingAmount] - Amount of selling asset
9
+ * @property {number} [slippageTolerance] - Swap slippage tolerance (0.02 by default - 2%)
10
+ */
11
+
12
+ /**
13
+ * @param {QuoteParams} params
14
+ * @return {QuoteParams}
15
+ */
16
+ export function validateQuoteRequest(params) {
17
+ const {
18
+ sellingAsset, selling_asset, buyingAsset, buying_asset, sellingAmount, selling_amount,
19
+ buyingAmount, buying_amount, slippageTolerance, slippage_tolerance, ...other
20
+ } = params
21
+ const res = {
22
+ sellingAsset: parseAsset(sellingAsset || selling_asset, 'sellingAsset'),
23
+ buyingAsset: parseAsset(buyingAsset || buying_asset, 'buyingAsset'),
24
+ sellingAmount: parseAmount(sellingAmount || selling_amount, 'sellingAmount'),
25
+ //buyingAmount: parseAmount(buyingAmount || buying_amount, 'buyingAmount'),
26
+ slippageTolerance: parseSlippageTolerance(slippageTolerance || slippage_tolerance || 0.02, 'slippageTolerance')
27
+ }
28
+ if (res.buyingAsset === res.sellingAsset)
29
+ throw errors.invalidQuoteParam('buyingAsset', 'Buying asset can\'t be the same as selling asset')
30
+ if (res.sellingAmount === undefined)
31
+ throw errors.invalidQuoteParam('sellingAmount', 'Parameter "sellingAmount" parameter is required')
32
+ /*if (res.buyingAmount !== undefined && res.sellingAmount !== undefined)
33
+ throw errors.invalidQuoteParam('sellingAmount', 'Parameters "buyingAmount" and "sellingAmount" are mutually exclusive')*/
34
+ Object.assign(res, other) //add remaining optional params
35
+ Object.freeze(res)
36
+ return res
37
+ }
38
+
39
+ function parseAmount(amount, parameter) {
40
+ if (amount === undefined)
41
+ return undefined
42
+ const parsed = parseFloat(amount)
43
+ if (isNaN(parsed) || parsed <= 0)
44
+ throw errors.invalidQuoteParam(parameter, 'Invalid asset amount: ' + amount)
45
+ return amount
46
+ }
47
+
48
+ function parseSlippageTolerance(src, parameter) {
49
+ const tolerance = parseFloat(src)
50
+ if (isNaN(tolerance))
51
+ throw errors.invalidQuoteParam(parameter, 'Invalid slippage tolerance, number expected')
52
+ if (tolerance < 0)
53
+ throw errors.invalidQuoteParam(parameter, 'Slippage tolerance is too small, expected value >= 0')
54
+ if (tolerance > 0.5)
55
+ throw errors.invalidQuoteParam(parameter, 'Slippage tolerance is too large, expected value < 0.5 (50%)')
56
+ return tolerance
61
57
  }
@@ -0,0 +1,93 @@
1
+ import {fromStroops, toStroops} from './stroops.js'
2
+
3
+ /**
4
+ * @typedef {'unfeasible'|'rejected'|'success'} QuoteResultStatus - Result status code returned by the server
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} QuoteDirectTradeResult - Emulated single `path_payment` trades result corresponding to the quote
9
+ * @property {string} selling - Estimated amount to sell
10
+ * @property {string} buying - Estimated amount to buy
11
+ * @property {string[]} path - Conversion path
12
+ */
13
+
14
+ /**
15
+ * Quote received from the server
16
+ */
17
+ export class QuoteResult {
18
+ /**
19
+ * @param {{}} result
20
+ * @package
21
+ */
22
+ constructor(result) {
23
+ Object.assign(this, result)
24
+ if (result.estimatedBuyingAmount && result.directTrade) {
25
+ const profit = toStroops(result.estimatedBuyingAmount) - toStroops(result.directTrade.buying)
26
+ if (profit > 0) {
27
+ this.profit = fromStroops(profit)
28
+ }
29
+ }
30
+ this.ts = new Date()
31
+ }
32
+
33
+ /**
34
+ * Quote timestamp
35
+ * @type {Date}
36
+ * @readonly
37
+ */
38
+ ts
39
+ /**
40
+ * Quote status code
41
+ * @type {QuoteResultStatus}
42
+ * @readonly
43
+ */
44
+ status
45
+ /**
46
+ * Asset to sell
47
+ * @type {string}
48
+ * @readonly
49
+ */
50
+ sellingAsset
51
+ /**
52
+ * Asset to buy
53
+ * @type {string}
54
+ * @readonly
55
+ */
56
+ buyingAsset
57
+ /**
58
+ * Swap slippage tolerance
59
+ * @type {number}
60
+ * @readonly
61
+ */
62
+ slippageTolerance
63
+ /**
64
+ * Amount of the selling asset
65
+ * @type {string}
66
+ * @readonly
67
+ */
68
+ sellingAmount
69
+ /**
70
+ * Estimated amount of buyingAsset to receive
71
+ * @type {string}
72
+ * @readonly
73
+ */
74
+ estimatedBuyingAmount
75
+ /**
76
+ * Equivalent direct path_payment trade estimate
77
+ * @type {QuoteDirectTradeResult}
78
+ * @readonly
79
+ */
80
+ directTrade
81
+ /**
82
+ * Difference between quoted price and estimated direct trade
83
+ * @type {string}
84
+ * @readonly
85
+ */
86
+ profit = '0'
87
+ /**
88
+ * Error details from the server (for rejected quote requests)
89
+ * @type {string}
90
+ * @readonly
91
+ */
92
+ error
93
+ }