@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.
package/src/client.js CHANGED
@@ -1,369 +1,344 @@
1
- import {Networks, Keypair, TransactionI, StrKey} from '@stellar/stellar-sdk'
2
- import errors from './errors.js'
3
- import {buildEvent} from './events.js'
4
- import {validateQuoteRequest} from './quote-request.js'
5
- import {processTxRequest} from './tx-processor.js'
6
-
7
- export default class StellarBrokerClient {
8
- /**
9
- * @param {ClientInitializationParams} params
10
- */
11
- constructor(params) {
12
- this.partnerKey = params.partnerKey
13
- this.emitter = new EventTarget()
14
- this.network = Networks[(params.network || 'PUBLIC').toUpperCase()] || params.network
15
- this.flow = params.flow || 'direct'
16
- if (!params.account || !StrKey.isValidEd25519PublicKey(params.account))
17
- throw errors.invalidQuoteParam('account', 'Invalid trader account address: ' + (!params.account ? 'missing' : params.account))
18
- this.trader = params.account
19
- if (typeof params.authorization === 'string') {
20
- try {
21
- this.authorization = Keypair.fromSecret(params.authorization)
22
- } catch (e) {
23
- throw errors.invalidAuthorizationParam()
24
- }
25
- } else if (typeof params.authorization === 'function') {
26
- this.authorization = params.authorization
27
- }
28
- }
29
-
30
- /**
31
- * @type {string}
32
- * @private
33
- */
34
- uid
35
- /**
36
- * @type {ClientStatus}
37
- * @readonly
38
- */
39
- status = 'disconnected'
40
- /**
41
- * @type {WebSocket}
42
- * @private
43
- */
44
- socket
45
- /**
46
- * @type {EventTarget}
47
- * @private
48
- */
49
- emitter
50
- /**
51
- * @type {string}
52
- * @private
53
- */
54
- origin = 'https://api.stellar.broker'
55
- /**
56
- * Stellar network passphrase
57
- * @type {string}
58
- * @readonly
59
- */
60
- network = ''
61
- /**
62
- * Last quote request submitted by the client
63
- * @type {SwapQuoteParams}
64
- * @readonly
65
- */
66
- quoteRequest
67
- /**
68
- * Last quote received from the sever
69
- * @type {SwapQuoteResult}
70
- * @readonly
71
- */
72
- lastQuote
73
- /**
74
- * Current quote accepted for the trading
75
- * @private
76
- */
77
- tradeQuote
78
- /**
79
- * API key of the partner
80
- * @type {string}
81
- * @readonly
82
- */
83
- partnerKey
84
- /**
85
- * Account secret key (for direct swap flow)
86
- * @type {Keypair|ClientAuthorizationCallback}
87
- * @private
88
- */
89
- authorization
90
- /**
91
- * Trader account public key
92
- * @type {string}
93
- * @readonly
94
- */
95
- trader
96
-
97
- /**
98
- * Connect to the StellarBroker server
99
- * @return {Promise<StellarBrokerClient>}
100
- */
101
- connect() {
102
- if (this.socket?.readyState === WebSocket.OPEN)
103
- return Promise.resolve(this) //already opened
104
- this.socket = new WebSocket(this.origin + '/ws?partner=' + encodeURIComponent(this.partnerKey))
105
- this.socket.onmessage = this.processMessage.bind(this)
106
- this.socket.onclose = () => {
107
- if (this.status !== 'disconnected') {
108
- this.status = 'disconnected'
109
- this.notifyError(errors.notConnected())
110
- }
111
- console.log('Connection closed')
112
- }
113
-
114
- this.socket.onerror = e => console.error(e)
115
-
116
- return new Promise((confirm, reject) => { //TODO: use 'once'
117
- const expirationTimeout = setTimeout(() => reject(this), 2000) // 2s timeout
118
- this.onSocketOpen = () => {
119
- this.status = 'ready'
120
- this.heartbeat()
121
- this.onSocketOpen = undefined
122
- clearTimeout(expirationTimeout)
123
- confirm(this)
124
- }
125
- })
126
- }
127
-
128
- /**
129
- * Process incoming message
130
- * @param message
131
- * @private
132
- */
133
- processMessage(message) {
134
- const raw = JSON.parse(message.data)
135
- switch (raw.type) {
136
- case 'connected':
137
- this.uid = raw.uid
138
- if (this.onSocketOpen) {
139
- this.onSocketOpen()
140
- }
141
- if (this.status === 'disconnected') {
142
- this.status = 'ready'
143
- }
144
- break
145
- case 'quote':
146
- this.lastQuote = raw.quote
147
- this.lastQuote.ts = new Date()
148
- //send event to the client app
149
- this.emitter.dispatchEvent(buildEvent('quote', this.lastQuote))
150
- break
151
- case 'paused':
152
- //quotation paused due to inactivity
153
- this.emitter.dispatchEvent(buildEvent('paused', {}))
154
- break
155
- case 'tx':
156
- if (this.status !== 'trade') {
157
- console.log('Received tx in non-trading state', this.status, raw)
158
- return //skip unless trading is in progress
159
- }
160
- processTxRequest(this, raw)
161
- .then(xdr => {
162
- this.send({
163
- type: 'tx',
164
- hash: raw.hash,
165
- xdr
166
- })
167
- })
168
- .catch(e => this.notifyError(e))
169
- break
170
- case 'stop':
171
- this.emitter.dispatchEvent(buildEvent('finished', {
172
- status: raw.status,
173
- sold: raw.sold,
174
- bought: raw.bought
175
- }, 'result'))
176
- this.status = 'ready'
177
- this.tradeQuote = undefined
178
- break
179
- case 'progress':
180
- this.emitter.dispatchEvent(buildEvent('progress', {
181
- sold: raw.sold,
182
- bought: raw.bought
183
- }, 'status'))
184
- break
185
- case 'ping':
186
- if (raw.uid === this.uid) {
187
- this.heartbeat()
188
- this.send({type: 'pong', uid: this.uid})
189
- }
190
- break
191
- case 'error':
192
- this.stop()
193
- this.notifyError(raw.error)
194
- break
195
- default:
196
- console.log('Unknown message type: ' + raw.type)
197
- break
198
- }
199
- }
200
-
201
- /**
202
- * Request swap quote
203
- * @param {SwapQuoteParams} params - Quote parameters
204
- */
205
- quote(params) {
206
- if (this.status === 'trade')
207
- throw errors.tradeInProgress()
208
- this.quoteRequest = validateQuoteRequest(params)
209
- this.status = 'quote'
210
- this.connect()
211
- .then(() => {
212
- this.status = 'quote'
213
- this.send({
214
- type: 'quote',
215
- ...this.quoteRequest
216
- })
217
- })
218
- }
219
-
220
- confirmQuote() {
221
- if (this.status === 'disconnected')
222
- throw errors.notConnected()
223
- if (this.status === 'trade')
224
- throw errors.tradeInProgress()
225
- if (this.status !== 'quote' || !this.lastQuote)
226
- throw errors.quoteNotSet()
227
- if ((new Date() - this.lastQuote.ts) > 7000) //do not allow stale quotes quoted more than 7s ago
228
- throw errors.quoteExpired()
229
- if (this.lastQuote.status !== 'success')
230
- throw errors.quoteError(this.lastQuote.error || 'quote not available')
231
-
232
- this.tradeQuote = this.lastQuote
233
- this.send({
234
- type: 'trade',
235
- account: this.trader
236
- })
237
- this.status = 'trade'
238
- }
239
-
240
- /**
241
- * Stop quotation/trading
242
- */
243
- stop() {
244
- if (this.status !== 'trade' && this.status !== 'quote')
245
- return
246
- this.send({type: 'stop'})
247
- this.tradeQuote = undefined
248
- this.status = 'ready'
249
- }
250
-
251
- /**
252
- * @param {{}} data
253
- * @private
254
- */
255
- send(data) {
256
- this.socket.send(JSON.stringify(data))
257
- }
258
-
259
- /**
260
- * @private
261
- */
262
- heartbeat() {
263
- clearTimeout(this.pingHandler)
264
- this.pingHandler = setTimeout(() => {
265
- console.warn('Lost connection with the server')
266
- this.socket.close()
267
- }, 7_000) // 7 seconds heartbeat timeout
268
- }
269
-
270
- /**
271
- * Add event listener
272
- * @param {StellarBrokerClientEvent} type
273
- * @param {function} callback
274
- */
275
- on(type, callback) {
276
- validateEventType(type)
277
- this.emitter.addEventListener(type, callback)
278
- }
279
-
280
- /**
281
- * Add event listener that will be executed once
282
- * @param {StellarBrokerClientEvent} type
283
- * @param {function} callback
284
- */
285
- once(type, callback) {
286
- validateEventType(type)
287
- this.emitter.addEventListener(type, callback, {once: true})
288
- }
289
-
290
- /**
291
- * Remove event listener
292
- * @param {StellarBrokerClientEvent} type
293
- * @param {function} callback
294
- */
295
- off(type, callback) {
296
- validateEventType(type)
297
- this.emitter.removeEventListener(type, callback)
298
- }
299
-
300
- /**
301
- * Close underlying connection and finalize the client
302
- */
303
- close() {
304
- try {
305
- this.status = 'disconnected'
306
- this.socket.close()
307
- } catch (e) {
308
- }
309
- }
310
-
311
- /**
312
- * @param {Error} e
313
- * @private
314
- */
315
- notifyError(e) {
316
- console.error(e)
317
- try {
318
- this.emitter.dispatchEvent(buildEvent('error', e instanceof Error ? e.message : e))
319
- } catch (e) {
320
- console.error(e)
321
- }
322
- }
323
- }
324
-
325
- function validateEventType(type) {
326
- if (!StellarBrokerEvents.includes(type))
327
- throw errors.unsupportedEventType(type)
328
- }
329
-
330
- export const StellarBrokerEvents = ['quote', 'paused', 'progress', 'finished', 'error']
331
-
332
- /**
333
- * @typedef {object} ClientInitializationParams
334
- * @property {string} [network] - Stellar network identifier or passphrase
335
- * @property {string} [partnerKey] - Partner key
336
- * @property {SwapFlowMode} [flow] - Swap flow mode
337
- * @property {string} account - Trader account address
338
- * @property {string|ClientAuthorizationCallback} authorization - Authorization method, either account secret key or an authorization callback
339
- */
340
-
341
- /**
342
- * @typedef {'disconnected'|'ready'|'quote'|'trade'} ClientStatus
343
- */
344
-
345
- /**
346
- * @typedef {'quote'|'paused'|'progress'|'finished'|'error'} StellarBrokerClientEvent
347
- */
348
-
349
- /**
350
- * @typedef {'direct'} SwapFlowMode
351
- */
352
-
353
- /**
354
- * @typedef {object} SwapQuoteResult
355
- * @property {string} sellingAsset
356
- * @property {string} buyingAsset
357
- * @property {number} slippageTolerance
358
- * @property {string} destination
359
- * @property {number} ledger
360
- * @property {string} [sellingAmount]
361
- * @property {string} [estimatedBuyingAmount]
362
- * @property {string} [buyingAmount]
363
- * @property {string} [estimatedSellingAmount]
364
- * @property {{selling: string, buying: string, path: string[]}} [directTrade]
365
- */
366
-
367
- /**
368
- * @typedef {function(TransactionI|Buffer):Promise<TransactionI|Buffer>} ClientAuthorizationCallback
369
- */
1
+ import {Networks, StrKey} from '@stellar/stellar-sdk'
2
+ import errors from './errors.js'
3
+ import {buildEvent} from './events.js'
4
+ import {processTxRequest} from './tx-processor.js'
5
+ import {validateQuoteRequest} from './quote-request.js'
6
+ import {QuoteResult} from './quote-result.js'
7
+ import {AuthorizationWrapper} from './authorization.js'
8
+
9
+ /**
10
+ * Client for StellarBroker service
11
+ */
12
+ export class StellarBrokerClient {
13
+ /**
14
+ * @param {ClientInitializationParams} params
15
+ */
16
+ constructor(params) {
17
+ this.partnerKey = params.partnerKey
18
+ this.emitter = new EventTarget()
19
+ this.network = Networks.PUBLIC
20
+ if (!params.account || !StrKey.isValidEd25519PublicKey(params.account))
21
+ throw errors.invalidQuoteParam('account', 'Invalid trader account address: ' + (!params.account ? 'missing' : params.account))
22
+ this.trader = params.account
23
+ this.authorization = new AuthorizationWrapper(params.authorization)
24
+ }
25
+
26
+ /**
27
+ * @type {string}
28
+ * @private
29
+ */
30
+ uid
31
+ /**
32
+ * @type {ClientSessionStatus}
33
+ * @readonly
34
+ */
35
+ status = 'disconnected'
36
+ /**
37
+ * @type {WebSocket}
38
+ * @private
39
+ */
40
+ socket
41
+ /**
42
+ * @type {EventTarget}
43
+ * @private
44
+ */
45
+ emitter
46
+ /**
47
+ * @type {string}
48
+ * @private
49
+ */
50
+ origin = 'https://api.stellar.broker'
51
+ /**
52
+ * Stellar network passphrase
53
+ * @type {string}
54
+ * @readonly
55
+ */
56
+ network = ''
57
+ /**
58
+ * Last quote request submitted by the client
59
+ * @type {QuoteParams}
60
+ * @readonly
61
+ */
62
+ quoteRequest
63
+ /**
64
+ * Last quote received from the sever
65
+ * @type {QuoteResult}
66
+ * @readonly
67
+ */
68
+ lastQuote
69
+ /**
70
+ * Current quote accepted for the trading
71
+ * @private
72
+ */
73
+ tradeQuote
74
+ /**
75
+ * API key of the partner
76
+ * @type {string}
77
+ * @readonly
78
+ */
79
+ partnerKey
80
+ /**
81
+ * Account secret key or authorization callback
82
+ * @type {AuthorizationWrapper}
83
+ * @private
84
+ */
85
+ authorization
86
+ /**
87
+ * Trader account public key
88
+ * @type {string}
89
+ * @readonly
90
+ */
91
+ trader
92
+
93
+ /**
94
+ * Connect to the StellarBroker server
95
+ * @return {Promise<StellarBrokerClient>}
96
+ */
97
+ connect() {
98
+ if (this.socket?.readyState === WebSocket.OPEN)
99
+ return Promise.resolve(this) //already opened
100
+ this.socket = new WebSocket(this.origin + '/ws?partner=' + encodeURIComponent(this.partnerKey))
101
+ this.socket.onmessage = this.processMessage.bind(this)
102
+ this.socket.onclose = () => {
103
+ if (this.status !== 'disconnected') {
104
+ this.status = 'disconnected'
105
+ this.notifyError(errors.notConnected())
106
+ }
107
+ console.log('Connection closed')
108
+ }
109
+
110
+ this.socket.onerror = e => console.error(e)
111
+
112
+ return new Promise((confirm, reject) => { //TODO: use 'once'
113
+ const expirationTimeout = setTimeout(() => reject(this), 2000) // 2s timeout
114
+ this.onSocketOpen = () => {
115
+ this.status = 'ready'
116
+ this.heartbeat()
117
+ this.onSocketOpen = undefined
118
+ clearTimeout(expirationTimeout)
119
+ confirm(this)
120
+ }
121
+ })
122
+ }
123
+
124
+ /**
125
+ * Process incoming message
126
+ * @param message
127
+ * @private
128
+ */
129
+ processMessage(message) {
130
+ const raw = JSON.parse(message.data)
131
+ switch (raw.type) {
132
+ case 'connected':
133
+ this.uid = raw.uid
134
+ if (this.onSocketOpen) {
135
+ this.onSocketOpen()
136
+ }
137
+ if (this.status === 'disconnected') {
138
+ this.status = 'ready'
139
+ }
140
+ break
141
+ case 'quote':
142
+ this.lastQuote = new QuoteResult(raw.quote)
143
+ //send event to the client app
144
+ this.emitter.dispatchEvent(buildEvent('quote', this.lastQuote))
145
+ break
146
+ case 'paused':
147
+ //quotation paused due to inactivity
148
+ this.emitter.dispatchEvent(buildEvent('paused', {}))
149
+ break
150
+ case 'tx':
151
+ if (this.status !== 'trade') {
152
+ console.log('Received tx in non-trading state', this.status, raw)
153
+ return //skip unless trading is in progress
154
+ }
155
+ processTxRequest(this, raw)
156
+ .then(xdr => {
157
+ this.send({
158
+ type: 'tx',
159
+ hash: raw.hash,
160
+ xdr
161
+ })
162
+ })
163
+ .catch(e => this.notifyError(e))
164
+ break
165
+ case 'stop':
166
+ this.emitter.dispatchEvent(buildEvent('finished', {
167
+ status: raw.status,
168
+ sold: raw.sold,
169
+ bought: raw.bought
170
+ }, 'result'))
171
+ this.status = 'ready'
172
+ this.tradeQuote = undefined
173
+ break
174
+ case 'progress':
175
+ this.emitter.dispatchEvent(buildEvent('progress', {
176
+ sold: raw.sold,
177
+ bought: raw.bought
178
+ }, 'status'))
179
+ break
180
+ case 'ping':
181
+ if (raw.uid === this.uid) {
182
+ this.heartbeat()
183
+ this.send({type: 'pong', uid: this.uid})
184
+ }
185
+ break
186
+ case 'error':
187
+ this.stop()
188
+ this.notifyError(raw.error)
189
+ break
190
+ default:
191
+ console.log('Unknown message type: ' + raw.type)
192
+ break
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Request swap quote
198
+ * @param {QuoteParams} params - Quote parameters
199
+ */
200
+ quote(params) {
201
+ if (this.status === 'trade')
202
+ throw errors.tradeInProgress()
203
+ this.quoteRequest = validateQuoteRequest(params)
204
+ this.status = 'quote'
205
+ this.connect()
206
+ .then(() => {
207
+ this.status = 'quote'
208
+ this.send({
209
+ type: 'quote',
210
+ ...this.quoteRequest
211
+ })
212
+ })
213
+ }
214
+
215
+ /**
216
+ * Confirm current quote and start trading
217
+ */
218
+ confirmQuote() {
219
+ if (this.status === 'disconnected')
220
+ throw errors.notConnected()
221
+ if (this.status === 'trade')
222
+ throw errors.tradeInProgress()
223
+ if (this.status !== 'quote' || !this.lastQuote)
224
+ throw errors.quoteNotSet()
225
+ if ((new Date() - this.lastQuote.ts) > 10_000) //do not allow stale quotes quoted more than 10s ago
226
+ throw errors.quoteExpired()
227
+ if (this.lastQuote.status !== 'success')
228
+ throw errors.quoteError(this.lastQuote.error || 'quote not available')
229
+
230
+ this.tradeQuote = this.lastQuote
231
+ this.send({
232
+ type: 'trade',
233
+ account: this.trader
234
+ })
235
+ this.status = 'trade'
236
+ }
237
+
238
+ /**
239
+ * Stop quotation/trading
240
+ */
241
+ stop() {
242
+ if (this.status !== 'trade' && this.status !== 'quote')
243
+ return
244
+ this.send({type: 'stop'})
245
+ this.tradeQuote = undefined
246
+ this.status = 'ready'
247
+ }
248
+
249
+ /**
250
+ * @param {{}} data
251
+ * @private
252
+ */
253
+ send(data) {
254
+ this.socket.send(JSON.stringify(data))
255
+ }
256
+
257
+ /**
258
+ * @private
259
+ */
260
+ heartbeat() {
261
+ clearTimeout(this.pingHandler)
262
+ this.pingHandler = setTimeout(() => {
263
+ console.warn('Lost connection with the server')
264
+ this.socket.close()
265
+ }, 7_000) // 7 seconds heartbeat timeout
266
+ }
267
+
268
+ /**
269
+ * Add event listener
270
+ * @param {StellarBrokerClientEvent} type
271
+ * @param {function} callback
272
+ */
273
+ on(type, callback) {
274
+ validateEventType(type)
275
+ this.emitter.addEventListener(type, callback)
276
+ }
277
+
278
+ /**
279
+ * Add event listener that will be executed once
280
+ * @param {StellarBrokerClientEvent} type
281
+ * @param {function} callback
282
+ */
283
+ once(type, callback) {
284
+ validateEventType(type)
285
+ this.emitter.addEventListener(type, callback, {once: true})
286
+ }
287
+
288
+ /**
289
+ * Remove event listener
290
+ * @param {StellarBrokerClientEvent} type
291
+ * @param {function} callback
292
+ */
293
+ off(type, callback) {
294
+ validateEventType(type)
295
+ this.emitter.removeEventListener(type, callback)
296
+ }
297
+
298
+ /**
299
+ * Close underlying connection and finalize the client
300
+ */
301
+ close() {
302
+ try {
303
+ //TODO: remove all attached event listeners
304
+ this.status = 'disconnected'
305
+ this.socket.close()
306
+ } catch (e) {
307
+ }
308
+ }
309
+
310
+ /**
311
+ * @param {Error} e
312
+ * @private
313
+ */
314
+ notifyError(e) {
315
+ console.error(e)
316
+ try {
317
+ this.emitter.dispatchEvent(buildEvent('error', e instanceof Error ? e.message : e))
318
+ } catch (e) {
319
+ console.error(e)
320
+ }
321
+ }
322
+ }
323
+
324
+ const stellarBrokerEvents = new Set(['quote', 'paused', 'progress', 'finished', 'error'])
325
+
326
+ function validateEventType(type) {
327
+ if (!stellarBrokerEvents.has(type))
328
+ throw errors.unsupportedEventType(type)
329
+ }
330
+
331
+ /**
332
+ * @typedef {object} ClientInitializationParams
333
+ * @property {string} account - Trader account address
334
+ * @property {ClientAuthorizationParams} authorization - Authorization method, either account secret key or an authorization callback
335
+ * @property {string} [partnerKey] - Partner key
336
+ */
337
+
338
+ /**
339
+ * @typedef {'disconnected'|'ready'|'quote'|'trade'} ClientSessionStatus - Current client session status
340
+ */
341
+
342
+ /**
343
+ * @typedef {'quote'|'paused'|'progress'|'finished'|'error'} StellarBrokerClientEvent - Event type generated by the client
344
+ */