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