@stellar-broker/client 0.4.1 → 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/errors.js CHANGED
@@ -1,54 +1,56 @@
1
- export class StellarBrokerError extends Error {
2
- constructor(code, message) {
3
- super(message)
4
- this.code = code
5
- }
6
-
7
- /**
8
- * Numeric error code
9
- * @type {number}
10
- * @readonly
11
- */
12
- code = 0
13
- }
14
-
15
- const errors = {
16
- invalidInitParam(param) {
17
- return new StellarBrokerError(1, `Invalid parameter value: "${param}"`)
18
- },
19
- invalidAuthorizationParam() {
20
- return new StellarBrokerError(2, 'Invalid authorization secret key or callback provided')
21
- },
22
- notConnected() {
23
- return new StellarBrokerError(3, 'Client not connected to the server')
24
- },
25
- quoteNotSet() {
26
- return new StellarBrokerError(11, 'Price quote not available')
27
- },
28
- quoteExpired() {
29
- return new StellarBrokerError(12, 'Price quote expired')
30
- },
31
- quoteError(message) {
32
- return new StellarBrokerError(13, 'Price quotation error: ' + message)
33
- },
34
- invalidQuoteParam(invalidParamName = 'asset', details) {
35
- return new StellarBrokerError(14, `Invalid quote request parameter: "${invalidParamName}". ${details}`)
36
- },
37
- tradeInProgress() {
38
- return new StellarBrokerError(20, 'Cannot change quote while trade is in progress')
39
- },
40
- invalidSwapTx() {
41
- return new StellarBrokerError(21, 'Invalid swap transaction received from the server')
42
- },
43
- failedToSignTx() {
44
- return new StellarBrokerError(22, 'Failed to sign received transaction')
45
- },
46
- unsupportedEventType(type) {
47
- return new StellarBrokerError(31, 'Unknown event type: ' + type)
48
- },
49
- serverError(message) {
50
- return new StellarBrokerError(101, message)
51
- }
52
- }
53
-
1
+ export class StellarBrokerError extends Error {
2
+ constructor(code, message) {
3
+ super(message)
4
+ this.code = code
5
+ }
6
+
7
+ /**
8
+ * Numeric error code
9
+ * @type {number}
10
+ * @readonly
11
+ */
12
+ code = 0
13
+ }
14
+
15
+ const errors = {
16
+ invalidInitParam(param) {
17
+ return new StellarBrokerError(1, `Invalid parameter value: "${param}"`)
18
+ },
19
+ invalidAuthorizationParam() {
20
+ return new StellarBrokerError(2, 'Invalid authorization secret key or callback provided')
21
+ },
22
+ notConnected() {
23
+ return new StellarBrokerError(3, 'Client not connected to the server')
24
+ },
25
+ quoteNotSet() {
26
+ return new StellarBrokerError(11, 'Price quote not available')
27
+ },
28
+ quoteExpired() {
29
+ return new StellarBrokerError(12, 'Price quote expired')
30
+ },
31
+ quoteError(message) {
32
+ return new StellarBrokerError(13, 'Price quotation error: ' + message)
33
+ },
34
+ invalidQuoteParam(invalidParamName = 'asset', details) {
35
+ return new StellarBrokerError(14, `Invalid quote request parameter: "${invalidParamName}". ${details}`)
36
+ },
37
+ tradeInProgress() {
38
+ return new StellarBrokerError(20, 'Cannot change quote while trade is in progress')
39
+ },
40
+ invalidSwapTx() {
41
+ return new StellarBrokerError(21, 'Invalid swap transaction received from the server')
42
+ },
43
+ failedToSignTx() {
44
+ return new StellarBrokerError(22, 'Failed to sign received transaction')
45
+ },
46
+ unsupportedEventType(type) {
47
+ return new StellarBrokerError(31, 'Unknown event type: ' + type)
48
+ },
49
+
50
+
51
+ serverError(message) {
52
+ return new StellarBrokerError(101, message)
53
+ }
54
+ }
55
+
54
56
  export default errors
package/src/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- import {FeeBumpTransaction} from "@stellar/stellar-sdk";
1
+ import {Asset, FeeBumpTransaction, TransactionI} from "@stellar/stellar-sdk";
2
2
 
3
+ /**
4
+ * Client for StellarBroker service
5
+ */
3
6
  export default class StellarBrokerClient {
4
7
  /**
5
8
  * @param {ClientInitializationParams} params
@@ -10,7 +13,7 @@ export default class StellarBrokerClient {
10
13
  * Trader account public key
11
14
  * @type {string}
12
15
  */
13
- readonly source: string;
16
+ readonly trader: string;
14
17
  /**
15
18
  * Stellar network passphrase
16
19
  * @type {string}
@@ -40,10 +43,8 @@ export default class StellarBrokerClient {
40
43
 
41
44
  /**
42
45
  * Confirm current quote and start trading
43
- * @param {string} account - Trader account address
44
- * @param {string|ClientAuthorizationCallback} authorization - Authorization method, either account secret key or an authorization callback
45
46
  */
46
- confirmQuote(account: string, authorization: string | ClientAuthorizationCallback): void;
47
+ confirmQuote(): void;
47
48
 
48
49
  /**
49
50
  * Add event listener
@@ -91,11 +92,7 @@ export interface SwapQuoteParams {
91
92
  */
92
93
  sellingAmount?: string
93
94
  /**
94
- * Amount of buying asset
95
- */
96
- buyingAmount?: string
97
- /**
98
- * Swap slippage tolerance (2% by default)
95
+ * Swap slippage tolerance (0.02 by default - 2%)
99
96
  */
100
97
  slippageTolerance?: number
101
98
  }
@@ -103,36 +100,56 @@ export interface SwapQuoteParams {
103
100
 
104
101
  export interface ClientInitializationParams {
105
102
  /**
106
- * Partner key
103
+ * Trader account address
107
104
  */
108
- partnerKey?: string;
105
+ account: string;
109
106
  /**
110
- * Swap flow mode
107
+ * Partner key
111
108
  */
112
- flow?: SwapFlowMode;
109
+ partnerKey?: string;
113
110
  /**
114
- * Stellar network identifier or passphrase (pubnet by default)
111
+ * Authorization method, either account secret key or an authorization callback
115
112
  */
116
- network?: string;
113
+ authorization: ClientAuthorizationParams;
117
114
  }
118
115
 
119
116
  export type ClientStatus = "disconnected" | "ready" | "quote" | "trade";
120
117
 
121
- export type StellarBrokerClientEvent = "quote" | "finished" | "progress" | "error";
122
-
123
- export type SwapFlowMode = "direct";
118
+ export type StellarBrokerClientEvent = "quote" | "progress" | "paused" | "finished" | "error";
124
119
 
125
120
  export interface SwapQuoteResult {
121
+ /**
122
+ * Quote timestamp formatted as ISO date
123
+ */
124
+ ts: string,
125
+ /**
126
+ * Asset to sell
127
+ */
126
128
  sellingAsset: string;
129
+ /**
130
+ * Asset to buy
131
+ */
127
132
  buyingAsset: string;
133
+ /**
134
+ * Swap slippage tolerance
135
+ */
128
136
  slippageTolerance: number;
129
- destination: string;
130
- ledger: number;
131
- sellingAmount?: string;
137
+ /**
138
+ * Amount of the selling asset
139
+ */
140
+ sellingAmount: string;
141
+ /**
142
+ * Estimated amount of buyingAsset to receive
143
+ */
132
144
  estimatedBuyingAmount?: string;
133
- buyingAmount?: string;
134
- estimatedSellingAmount?: string;
145
+ /**
146
+ * Equivalent direct path_payment trade estimate
147
+ */
135
148
  directTrade?: SwapQuoteDirectTradeResult;
149
+ /**
150
+ * Error details from the server (for failed quotes)
151
+ */
152
+ error?: string;
136
153
  }
137
154
 
138
155
  export interface SwapQuoteDirectTradeResult {
@@ -150,4 +167,57 @@ export class StellarBrokerError extends Error {
150
167
  readonly code: number;
151
168
  }
152
169
 
153
- export type ClientAuthorizationCallback = (arg0: FeeBumpTransaction) => Promise<FeeBumpTransaction>;
170
+ /**
171
+ * Async authorization callback for transactions and simulated auth entries
172
+ */
173
+ export type ClientAuthorizationCallback = (txOrAuthEntry: TransactionI | Buffer) => Promise<TransactionI | Buffer>;
174
+
175
+ /**
176
+ * Authorization method, either account secret key or an authorization callback
177
+ */
178
+ export type ClientAuthorizationParams = string | ClientAuthorizationCallback;
179
+
180
+ /**
181
+ * Trader mediator account wrapper
182
+ */
183
+ export class Mediator {
184
+ /**
185
+ * Create a trader mediator account instance for a given source account
186
+ * @param source - Source account
187
+ * @param sellingAsset - Asset to sell
188
+ * @param buyingAsset - Asset to buy
189
+ * @param sellingAmount - Amount to sell
190
+ * @param authorization - Authorization callback or secret key
191
+ */
192
+ constructor(source: string, sellingAsset: string | Asset, buyingAsset: string | Asset, sellingAmount: string, authorization: ClientAuthorizationParams)
193
+
194
+ readonly source: string;
195
+
196
+ readonly sellingAsset: Asset;
197
+
198
+ readonly buyingAsset: Asset;
199
+
200
+ readonly sellingAmount: bigint;
201
+
202
+ readonly mediatorAddress: string;
203
+
204
+ /**
205
+ * Check if there are any non-disposed mediators that belong to lost swap sessions
206
+ */
207
+ get hasObsoleteMediators(): boolean;
208
+
209
+ /**
210
+ * Retrieve funds from mediator accounts that belong to lost swap sessions
211
+ */
212
+ disposeObsoleteMediators(): Promise<void>;
213
+
214
+ /**
215
+ * Create mediator account and deposit tokens to sell
216
+ */
217
+ init(): Promise<string>;
218
+
219
+ /**
220
+ * Dispose mediator account
221
+ */
222
+ dispose(address:string): Promise<void>;
223
+ }
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import StellarBrokerClient from './client.js'
2
- export * from './estimate.js'
3
-
1
+ import StellarBrokerClient from './client.js'
2
+ export * from './estimate.js'
3
+ export * from './mediator.js'
4
+
4
5
  export default StellarBrokerClient
@@ -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
+ }