@exodus/solana-api 2.0.16 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "2.0.16",
3
+ "version": "2.1.0",
4
4
  "description": "Exodus internal Solana asset API wrapper",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -14,13 +14,16 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@exodus/asset-json-rpc": "^1.0.0",
17
- "@exodus/asset-lib": "^3.6.0",
17
+ "@exodus/asset-lib": "^3.6.2",
18
18
  "@exodus/assets": "^8.0.71",
19
19
  "@exodus/assets-base": "^8.0.150",
20
+ "@exodus/fetch": "^1.2.0",
20
21
  "@exodus/models": "^8.7.2",
21
22
  "@exodus/nfts-core": "^0.5.0",
22
- "@exodus/solana-lib": "^1.3.14",
23
+ "@exodus/solana-lib": "^1.4.0",
24
+ "@ungap/url-search-params": "^0.2.2",
23
25
  "bn.js": "^4.11.0",
26
+ "debug": "^4.1.1",
24
27
  "lodash": "^4.17.11",
25
28
  "url-join": "4.0.0",
26
29
  "wretch": "^1.5.2"
@@ -28,5 +31,5 @@
28
31
  "devDependencies": {
29
32
  "node-fetch": "~2.6.0"
30
33
  },
31
- "gitHead": "9ec7084bcdfe14f1c6f11df393351885773046a6"
34
+ "gitHead": "6ffded97383f994c21ccaebb2703c32d48aa70c5"
32
35
  }
package/src/api.js CHANGED
@@ -20,17 +20,21 @@ import lodash from 'lodash'
20
20
  import urljoin from 'url-join'
21
21
  import wretch, { Wretcher } from 'wretch'
22
22
  import { magicEden } from '@exodus/nfts-core'
23
+ import { Connection } from './connection'
23
24
 
24
25
  // Doc: https://docs.solana.com/apps/jsonrpc-api
25
26
 
26
27
  const RPC_URL = 'https://solana.a.exodus.io' // https://vip-api.mainnet-beta.solana.com/, https://api.mainnet-beta.solana.com, https://solana-api.projectserum.com
28
+ const WS_ENDPOINT = 'wss://solana.a.exodus.io/ws'
27
29
 
28
30
  // Tokens + SOL api support
29
31
  export class Api {
30
- constructor(rpcUrl) {
32
+ constructor(rpcUrl, wsUrl) {
31
33
  this.setServer(rpcUrl)
34
+ this.setWsEndpoint(wsUrl)
32
35
  this.setTokens(assets)
33
36
  this.tokensToSkip = {}
37
+ this.connections = {}
34
38
  }
35
39
 
36
40
  setServer(rpcUrl) {
@@ -38,6 +42,10 @@ export class Api {
38
42
  this.api = createApi(this.rpcUrl)
39
43
  }
40
44
 
45
+ setWsEndpoint(wsUrl) {
46
+ this.wsUrl = wsUrl || WS_ENDPOINT
47
+ }
48
+
41
49
  setTokens(assets = {}) {
42
50
  const solTokens = lodash.pickBy(assets, (asset) => asset.assetType === 'SOLANA_TOKEN')
43
51
  this.tokens = lodash.mapKeys(solTokens, (v) => v.mintAddress)
@@ -49,76 +57,107 @@ export class Api {
49
57
  })
50
58
  }
51
59
 
60
+ async watchAddress({
61
+ address,
62
+ tokensAddresses = [],
63
+ handleAccounts,
64
+ handleTransfers,
65
+ handleReconnect,
66
+ reconnectDelay,
67
+ }) {
68
+ const conn = new Connection({
69
+ endpoint: this.wsUrl,
70
+ address,
71
+ tokensAddresses,
72
+ callback: (updates) =>
73
+ this.handleUpdates({ updates, address, handleAccounts, handleTransfers }),
74
+ reconnectCallback: handleReconnect,
75
+ reconnectDelay,
76
+ })
77
+
78
+ this.connections[address] = conn
79
+ return conn.start()
80
+ }
81
+
82
+ async unwatchAddress({ address }) {
83
+ if (this.connections[address]) {
84
+ await this.connections[address].stop()
85
+ delete this.connections[address]
86
+ }
87
+ }
88
+
89
+ async handleUpdates({ updates, address, handleAccounts, handleTransfers }) {
90
+ // console.log(`got ws updates from ${address}:`, updates)
91
+ if (handleTransfers) return handleTransfers(updates)
92
+ }
93
+
94
+ async rpcCall(method, params = [], { address = '', forceHttp = false } = {}) {
95
+ // ws request
96
+ const connection = this.connections[address] || lodash.sample(Object.values(this.connections)) // pick random connection
97
+ if (lodash.get(connection, 'isOpen') && !lodash.get(connection, 'shutdown') && !forceHttp) {
98
+ return connection.sendMessage(method, params)
99
+ }
100
+ // http fallback
101
+ return this.api.post({ method, params })
102
+ }
103
+
104
+ getTokenByAddress(mint: string) {
105
+ return this.tokens[mint]
106
+ }
107
+
52
108
  isTokenSupported(mint: string) {
53
- return !!this.tokens[mint]
109
+ return !!this.getTokenByAddress(mint)
54
110
  }
55
111
 
56
112
  async getEpochInfo(): number {
57
- const { epoch } = await this.api.post({
58
- method: 'getEpochInfo',
59
- })
113
+ const { epoch } = await this.rpcCall('getEpochInfo')
60
114
  return Number(epoch)
61
115
  }
62
116
 
63
117
  async getStakeActivation(address): string {
64
- const { state } = await this.api.post({
65
- method: 'getStakeActivation',
66
- params: [address],
67
- })
118
+ const { state } = await this.rpcCall('getStakeActivation', [address])
68
119
  return state
69
120
  }
70
121
 
71
- async getRecentBlockHash(): string {
72
- const {
73
- value: { blockhash },
74
- } = await this.api.post({
75
- method: 'getRecentBlockhash',
76
- })
77
- return blockhash
122
+ async getRecentBlockHash(commitment?: string): Promise<string> {
123
+ const result = await this.rpcCall(
124
+ 'getRecentBlockhash',
125
+ [{ commitment: commitment || 'finalized', encoding: 'jsonParsed' }],
126
+ { forceHttp: true }
127
+ )
128
+ return lodash.get(result, 'value.blockhash')
78
129
  }
79
130
 
80
131
  // Transaction structure: https://docs.solana.com/apps/jsonrpc-api#transaction-structure
81
132
  async getTransactionById(id: string) {
82
- const result = await this.api.post({
83
- method: 'getTransaction',
84
- params: [id, { encoding: 'jsonParsed', maxSupportedTransactionVersion: 0 }],
85
- })
86
- return result
133
+ return this.rpcCall('getTransaction', [
134
+ id,
135
+ { encoding: 'jsonParsed', maxSupportedTransactionVersion: 0 },
136
+ ])
87
137
  }
88
138
 
89
139
  async getFee(): number {
90
- const {
91
- value: {
92
- feeCalculator: { lamportsPerSignature },
93
- },
94
- } = await this.api.post({
95
- method: 'getRecentBlockhash',
96
- })
97
- return lamportsPerSignature
140
+ const result = await this.rpcCall('getRecentBlockhash', [
141
+ { commitment: 'finalized', encoding: 'jsonParsed' },
142
+ ])
143
+ return lodash.get(result, 'value.feeCalculator.lamportsPerSignature')
98
144
  }
99
145
 
100
146
  async getBalance(address: string): number {
101
- const res = await this.api.post({
102
- method: 'getBalance',
103
- params: [address],
147
+ const result = await this.rpcCall('getBalance', [address, { encoding: 'jsonParsed' }], {
148
+ address,
104
149
  })
105
- return res.value || 0
150
+ return lodash.get(result, 'value', 0)
106
151
  }
107
152
 
108
153
  async getBlockTime(slot: number) {
109
154
  // might result in error if executed on a validator with partial ledger (https://github.com/solana-labs/solana/issues/12413)
110
- return this.api.post({
111
- method: 'getBlockTime',
112
- params: [slot],
113
- })
155
+ return this.rpcCall('getBlockTime', [slot])
114
156
  }
115
157
 
116
158
  async getConfirmedSignaturesForAddress(address: string, { until, before, limit } = {}): any {
117
159
  until = until || undefined
118
- return this.api.post({
119
- method: 'getSignaturesForAddress',
120
- params: [address, { until, before, limit }],
121
- })
160
+ return this.rpcCall('getSignaturesForAddress', [address, { until, before, limit }], { address })
122
161
  }
123
162
 
124
163
  /**
@@ -199,14 +238,8 @@ export class Api {
199
238
  tokenAccountsByOwner: ?Array,
200
239
  { includeUnparsed = false } = {}
201
240
  ): Object {
202
- let {
203
- fee,
204
- preBalances,
205
- postBalances,
206
- preTokenBalances,
207
- postTokenBalances,
208
- innerInstructions,
209
- } = txDetails.meta
241
+ let { fee, preBalances, postBalances, preTokenBalances, postTokenBalances, innerInstructions } =
242
+ txDetails.meta
210
243
  preBalances = preBalances || []
211
244
  postBalances = postBalances || []
212
245
  preTokenBalances = preTokenBalances || []
@@ -525,13 +558,8 @@ export class Api {
525
558
  }
526
559
 
527
560
  async getSupply(mintAddress: string): string {
528
- const {
529
- value: { amount },
530
- } = await this.api.post({
531
- method: 'getTokenSupply',
532
- params: [mintAddress],
533
- })
534
- return amount
561
+ const result = await this.rpcCall('getTokenSupply', [mintAddress])
562
+ return lodash.get(result, 'value.amount')
535
563
  }
536
564
 
537
565
  async getWalletTokensList({ tokenAccounts }) {
@@ -557,10 +585,11 @@ export class Api {
557
585
  }
558
586
 
559
587
  async getTokenAccountsByOwner(address: string, tokenTicker: ?string): Array {
560
- const { value: accountsList } = await this.api.post({
561
- method: 'getTokenAccountsByOwner',
562
- params: [address, { programId: TOKEN_PROGRAM_ID.toBase58() }, { encoding: 'jsonParsed' }],
563
- })
588
+ const { value: accountsList } = await this.rpcCall(
589
+ 'getTokenAccountsByOwner',
590
+ [address, { programId: TOKEN_PROGRAM_ID.toBase58() }, { encoding: 'jsonParsed' }],
591
+ { address }
592
+ )
564
593
 
565
594
  const tokenAccounts = []
566
595
  for (let entry of accountsList) {
@@ -605,10 +634,7 @@ export class Api {
605
634
  async isAssociatedTokenAccountActive(tokenAddress: string) {
606
635
  // Returns the token balance of an SPL Token account.
607
636
  try {
608
- await this.api.post({
609
- method: 'getTokenAccountBalance',
610
- params: [tokenAddress],
611
- })
637
+ await this.rpcCall('getTokenAccountBalance', [tokenAddress])
612
638
  return true
613
639
  } catch (e) {
614
640
  return false
@@ -617,21 +643,16 @@ export class Api {
617
643
 
618
644
  // Returns account balance of a SPL Token account.
619
645
  async getTokenBalance(tokenAddress: string) {
620
- const {
621
- value: { amount },
622
- } = await this.api.post({
623
- method: 'getTokenAccountBalance',
624
- params: [tokenAddress],
625
- })
626
-
627
- return amount
646
+ const result = await this.rpcCall('getTokenAccountBalance', [tokenAddress])
647
+ return lodash.get(result, 'value.amount')
628
648
  }
629
649
 
630
650
  async getAccountInfo(address: string) {
631
- const { value } = await this.api.post({
632
- method: 'getAccountInfo',
633
- params: [address, { encoding: 'jsonParsed', commitment: 'single' }],
634
- })
651
+ const { value } = await this.rpcCall(
652
+ 'getAccountInfo',
653
+ [address, { encoding: 'jsonParsed', commitment: 'single' }],
654
+ { address }
655
+ )
635
656
  return value
636
657
  }
637
658
 
@@ -650,8 +671,8 @@ export class Api {
650
671
  }
651
672
 
652
673
  async getDecimals(tokenMintAddress: string) {
653
- const res = await this.api.post({ method: 'getTokenSupply', params: [tokenMintAddress] })
654
- return lodash.get(res, 'value.decimals', null)
674
+ const result = await this.rpcCall('getTokenSupply', [tokenMintAddress])
675
+ return lodash.get(result, 'value.decimals', null)
655
676
  }
656
677
 
657
678
  async getAddressType(address: string) {
@@ -695,24 +716,22 @@ export class Api {
695
716
  }
696
717
 
697
718
  async getStakeAccountsInfo(address: string) {
698
- // get staked amount and other info
699
- const res = await this.api.post({
700
- method: 'getProgramAccounts',
701
- params: [
702
- STAKE_PROGRAM_ID.toBase58(),
703
- {
704
- filters: [
705
- {
706
- memcmp: {
707
- offset: 12,
708
- bytes: address,
709
- },
719
+ const params = [
720
+ STAKE_PROGRAM_ID.toBase58(),
721
+ {
722
+ filters: [
723
+ {
724
+ memcmp: {
725
+ offset: 12,
726
+ bytes: address,
710
727
  },
711
- ],
712
- encoding: 'jsonParsed',
713
- },
714
- ],
715
- })
728
+ },
729
+ ],
730
+ encoding: 'jsonParsed',
731
+ },
732
+ ]
733
+ const res = await this.rpcCall('getProgramAccounts', params, { address })
734
+
716
735
  const accounts = {}
717
736
  let totalStake = 0
718
737
  let locked = 0
@@ -761,29 +780,15 @@ export class Api {
761
780
  }
762
781
 
763
782
  async getMinimumBalanceForRentExemption(size: number) {
764
- const minimumBalance = await this.api.post({
765
- method: 'getMinimumBalanceForRentExemption',
766
- params: [size],
767
- })
768
-
769
- return minimumBalance
783
+ return this.rpcCall('getMinimumBalanceForRentExemption', [size])
770
784
  }
771
785
 
772
786
  async getProgramAccounts(programId: string, config) {
773
- const response = await this.api.post({
774
- method: 'getProgramAccounts',
775
- params: [programId, config],
776
- })
777
-
778
- return response
787
+ return this.rpcCall('getProgramAccounts', [programId, config])
779
788
  }
780
789
 
781
790
  async getMultipleAccounts(pubkeys: string[], config) {
782
- const response = await this.api.post({
783
- method: 'getMultipleAccounts',
784
- params: [pubkeys, config],
785
- })
786
-
791
+ const response = await this.rpcCall('getMultipleAccounts', [pubkeys, config])
787
792
  return response && response.value ? response.value : []
788
793
  }
789
794
 
@@ -793,10 +798,8 @@ export class Api {
793
798
  broadcastTransaction = async (signedTx: string): string => {
794
799
  console.log('Solana broadcasting TX:', signedTx) // base64
795
800
 
796
- const result = await this.api.post({
797
- method: 'sendTransaction',
798
- params: [signedTx, { encoding: 'base64', commitment: 'singleGossip' }],
799
- })
801
+ const params = [signedTx, { encoding: 'base64', commitment: 'finalized' }]
802
+ const result = await this.rpcCall('sendTransaction', params, { forceHttp: true })
800
803
 
801
804
  console.log(`tx ${JSON.stringify(result)} sent!`)
802
805
  return result || null
@@ -805,10 +808,7 @@ export class Api {
805
808
  simulateTransaction = async (encodedTransaction, options) => {
806
809
  const {
807
810
  value: { accounts },
808
- } = await this.api.post({
809
- method: 'simulateTransaction',
810
- params: [encodedTransaction, options],
811
- })
811
+ } = await this.rpcCall('simulateTransaction', [encodedTransaction, options])
812
812
 
813
813
  return accounts
814
814
  }
@@ -0,0 +1,237 @@
1
+ import ms from 'ms'
2
+ import delay from 'delay'
3
+ import url from 'url'
4
+ import lodash from 'lodash'
5
+ import debugLogger from 'debug'
6
+ import { WebSocket } from '@exodus/fetch'
7
+
8
+ // WS subscriptions: https://docs.solana.com/developing/clients/jsonrpc-api#subscription-websocket
9
+
10
+ const SOLANA_DEFAULT_ENDPOINT = 'wss://solana.a.exodus.io/ws'
11
+ const DEFAULT_RECONNECT_DELAY = ms('15s')
12
+ const PING_INTERVAL = ms('50s')
13
+ const TIMEOUT = ms('20s')
14
+
15
+ const debug = debugLogger('exodus:solana-api')
16
+
17
+ export class Connection {
18
+ constructor({
19
+ endpoint = SOLANA_DEFAULT_ENDPOINT,
20
+ address,
21
+ tokensAddresses = [],
22
+ callback,
23
+ reconnectCallback = () => {},
24
+ reconnectDelay = DEFAULT_RECONNECT_DELAY,
25
+ }) {
26
+ this.address = address
27
+ this.tokensAddresses = tokensAddresses
28
+ this.endpoint = endpoint
29
+ this.callback = callback
30
+ this.reconnectCallback = reconnectCallback
31
+ this.reconnectDelay = reconnectDelay
32
+
33
+ this.shutdown = false
34
+ this.ws = null
35
+ this.rpcQueue = {}
36
+ this.messageQueue = []
37
+ this.inProcessMessages = false
38
+ this.pingTimeout = null
39
+ this.reconnectTimeout = null
40
+ this.txCache = {}
41
+ }
42
+
43
+ newSocket(reqUrl) {
44
+ // eslint-disable-next-line
45
+ const obj = url.parse(reqUrl)
46
+ obj.protocol = 'wss:'
47
+ reqUrl = url.format(obj)
48
+ debug('Opening WS to:', reqUrl)
49
+ const ws = new WebSocket(`${reqUrl}`)
50
+ ws.onmessage = this.onMessage.bind(this)
51
+ ws.onopen = this.onOpen.bind(this)
52
+ ws.onclose = this.onClose.bind(this)
53
+ ws.onerror = this.onError.bind(this)
54
+ return ws
55
+ }
56
+
57
+ get isConnecting() {
58
+ return !!(this.ws && this.ws.readyState === WebSocket.CONNECTING)
59
+ }
60
+
61
+ get isOpen() {
62
+ return !!(this.ws && this.ws.readyState === WebSocket.OPEN)
63
+ }
64
+
65
+ get isClosing() {
66
+ return !!(this.ws && this.ws.readyState === WebSocket.CLOSING)
67
+ }
68
+
69
+ get isClosed() {
70
+ return !!(!this.ws || this.ws.readyState === WebSocket.CLOSED)
71
+ }
72
+
73
+ get running() {
74
+ return !!(!this.isClosed || this.inProcessMessages || this.messageQueue.length)
75
+ }
76
+
77
+ get connectionState() {
78
+ if (this.isConnecting) return 'CONNECTING'
79
+ else if (this.isOpen) return 'OPEN'
80
+ else if (this.isClosing) return 'CLOSING'
81
+ else if (this.isClosed) return 'CLOSED'
82
+ return 'NONE'
83
+ }
84
+
85
+ doPing() {
86
+ if (this.ws) {
87
+ this.ws.ping()
88
+ this.pingTimeout = setTimeout(this.doPing.bind(this), PING_INTERVAL)
89
+ }
90
+ }
91
+
92
+ doRestart() {
93
+ // debug('Restarting WS:')
94
+ this.reconnectTimeout = setTimeout(async () => {
95
+ try {
96
+ debug('reconnecting ws...')
97
+ this.start()
98
+ await this.reconnectCallback()
99
+ } catch (e) {
100
+ console.log(`Error in reconnect callback: ${e.message}`)
101
+ }
102
+ }, this.reconnectDelay)
103
+ }
104
+
105
+ onMessage(evt) {
106
+ try {
107
+ const json = JSON.parse(evt.data)
108
+ debug('new ws msg:', json)
109
+ if (!json.error) {
110
+ if (lodash.get(this.rpcQueue, json.id)) {
111
+ // json-rpc reply
112
+ clearTimeout(this.rpcQueue[json.id].timeout)
113
+ this.rpcQueue[json.id].resolve(json.result)
114
+ delete this.rpcQueue[json.id]
115
+ } else if (json.method) {
116
+ const msg = { method: json.method, ...lodash.get(json, 'params.result', json.result) }
117
+ debug('pushing msg to queue', msg)
118
+ this.messageQueue.push(msg) // sub results
119
+ }
120
+ this.processMessages()
121
+ } else {
122
+ if (lodash.get(this.rpcQueue, json.id)) {
123
+ this.rpcQueue[json.id].reject(new Error(json.error.message))
124
+ clearTimeout(this.rpcQueue[json.id].timeout)
125
+ delete this.rpcQueue[json.id]
126
+ } else debug('Unsupported WS message:', json.error.message)
127
+ }
128
+ } catch (e) {
129
+ debug(e)
130
+ debug('Cannot parse msg:', evt.data)
131
+ }
132
+ }
133
+
134
+ onOpen(evt) {
135
+ debug('Opened WS')
136
+ // subscribe to each addresses (SOL and ASA addr)
137
+
138
+ this.tokensAddresses.concat(this.address).forEach((address) => {
139
+ // sub for account state changes
140
+ this.ws.send(
141
+ JSON.stringify({
142
+ jsonrpc: '2.0',
143
+ method: 'accountSubscribe',
144
+ params: [
145
+ address,
146
+ {
147
+ encoding: 'jsonParsed',
148
+ },
149
+ ],
150
+ id: 1,
151
+ })
152
+ )
153
+ // sub for incoming/outcoming txs
154
+ this.ws.send(
155
+ JSON.stringify({
156
+ jsonrpc: '2.0',
157
+ method: 'logsSubscribe',
158
+ params: [{ mentions: [address] }, { commitment: 'finalized' }],
159
+ id: 2,
160
+ })
161
+ )
162
+ })
163
+ // this.doPing()
164
+ }
165
+
166
+ onError(evt) {
167
+ debug('Error on WS:', evt.data)
168
+ }
169
+
170
+ onClose(evt) {
171
+ debug('Closing WS')
172
+ clearTimeout(this.pingTimeout)
173
+ clearTimeout(this.reconnectTimeout)
174
+ if (!this.shutdown) {
175
+ this.doRestart()
176
+ }
177
+ }
178
+
179
+ async sendMessage(method, params = []) {
180
+ return new Promise((resolve, reject) => {
181
+ if (this.isClosed || this.shutdown) return reject(new Error('connection not started'))
182
+ const id = Math.floor(Math.random() * 1e7) + 1
183
+
184
+ this.rpcQueue[id] = { resolve, reject }
185
+ this.rpcQueue[id].timeout = setTimeout(() => {
186
+ delete this.rpcQueue[id]
187
+ reject(new Error('solana ws: reply timeout'))
188
+ }, TIMEOUT)
189
+ this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params, id }))
190
+ })
191
+ }
192
+
193
+ async processMessages() {
194
+ if (this.inProcessMessages) return null
195
+ this.inProcessMessages = true
196
+ try {
197
+ while (this.messageQueue.length) {
198
+ const items = this.messageQueue.splice(0, this.messageQueue.length)
199
+ await this.callback(items)
200
+ }
201
+ } catch (e) {
202
+ console.log(`Solana: error processing streams: ${e.message}`)
203
+ } finally {
204
+ this.inProcessMessages = false
205
+ }
206
+ }
207
+
208
+ async close() {
209
+ clearTimeout(this.reconnectTimeout)
210
+ clearTimeout(this.pingTimeout)
211
+ if (this.ws && (this.isConnecting || this.isOpen)) {
212
+ // this.ws.send(JSON.stringify({ method: 'close' }))
213
+ await delay(ms('1s')) // allow for the 'close' round-trip
214
+ await this.ws.close()
215
+ await this.ws.terminate()
216
+ }
217
+ }
218
+
219
+ async start() {
220
+ try {
221
+ if (!this.isClosed || this.shutdown) return
222
+ this.ws = this.newSocket(this.endpoint)
223
+ } catch (e) {
224
+ console.log('Solana: error starting WS:', e)
225
+ this.doRestart()
226
+ }
227
+ }
228
+
229
+ async stop() {
230
+ if (this.shutdown) return
231
+ this.shutdown = true
232
+ await this.close()
233
+ while (this.running) {
234
+ await delay(ms('1s'))
235
+ }
236
+ }
237
+ } // Connection
package/src/index.js CHANGED
@@ -2,6 +2,7 @@ import { Api } from './api'
2
2
  export * from './api'
3
3
  export * from './tx-log'
4
4
  export * from './account-state'
5
+ export * from './pay'
5
6
 
6
7
  // At some point we would like to exclude this export. Default export should be the whole asset "plugin" ready to be injected.
7
8
  // Clients should not call an specific server api directly.
@@ -0,0 +1,3 @@
1
+ export * from './parseURL.js'
2
+ export * from './prepareSendData.js'
3
+ export * from './validateBeforePay'
@@ -0,0 +1,117 @@
1
+ // @flow
2
+ import { PublicKey } from '@exodus/solana-lib'
3
+ import BigNumber from 'bignumber.js'
4
+ import assets from '@exodus/assets'
5
+ import URLSearchParams from '@ungap/url-search-params'
6
+ import api from '..'
7
+
8
+ const SOLANA_PROTOCOL = 'solana:'
9
+ const HTTPS_PROTOCOL = 'https:'
10
+
11
+ export class ParseURLError extends Error {
12
+ name = 'ParseURLError'
13
+ }
14
+
15
+ export type TransactionRequestURL = {
16
+ link: URL,
17
+ label?: string,
18
+ message?: string,
19
+ }
20
+
21
+ export type TransferRequestURL = {
22
+ recipient: string,
23
+ reference?: Array<string>,
24
+ amount?: string,
25
+ splToken?: string,
26
+ message?: string,
27
+ memo?: string,
28
+ }
29
+
30
+ export type ParsedURL = TransactionRequestURL | TransferRequestURL
31
+
32
+ export function parseURL(url: string | URL): ParsedURL {
33
+ if (typeof url === 'string') {
34
+ if (url.length > 2048) throw new ParseURLError('length invalid')
35
+ url = new URL(url)
36
+ }
37
+ if (url.protocol !== SOLANA_PROTOCOL) throw new ParseURLError('protocol invalid')
38
+ if (!url.pathname) throw new ParseURLError('pathname missing')
39
+ return /[:%]/.test(url.pathname) ? parseTransactionRequestURL(url) : parseTransferRequestURL(url)
40
+ }
41
+
42
+ function parseTransactionRequestURL(url: URL): TransactionRequestURL {
43
+ const link = new URL(decodeURIComponent(url.pathname))
44
+ const searchParams = new URLSearchParams(url.search)
45
+ if (link.protocol !== HTTPS_PROTOCOL) throw new ParseURLError('link invalid')
46
+
47
+ const label = searchParams.get('label') || undefined
48
+ const message = searchParams.get('message') || undefined
49
+
50
+ return {
51
+ link,
52
+ label,
53
+ message,
54
+ }
55
+ }
56
+
57
+ function parseTransferRequestURL(url: URL): TransferRequestURL {
58
+ let recipient: PublicKey
59
+ try {
60
+ recipient = new PublicKey(url.pathname)
61
+ } catch (error) {
62
+ throw new Error('ParseURLError: recipient invalid')
63
+ }
64
+
65
+ const searchParams = new URLSearchParams(url.search)
66
+
67
+ let amount: BigNumber
68
+ const amountParam = searchParams.get('amount')
69
+ if (amountParam) {
70
+ if (!/^\d+(\.\d+)?$/.test(amountParam)) throw new Error('ParseURLError: amount invalid')
71
+ amount = new BigNumber(amountParam)
72
+ if (amount.isNaN()) throw new Error('ParseURLError: amount NaN')
73
+ if (amount.isNegative()) throw new Error('ParseURLError: amount negative')
74
+ }
75
+
76
+ let splToken: PublicKey
77
+ const splTokenParam = searchParams.get('spl-token')
78
+ if (splTokenParam) {
79
+ try {
80
+ splToken = new PublicKey(splTokenParam)
81
+ } catch (error) {
82
+ throw new ParseURLError('spl-token invalid')
83
+ }
84
+ }
85
+
86
+ let asset = assets.solana
87
+ if (splToken) {
88
+ if (!api.isTokenSupported(splToken))
89
+ throw new ParseURLError(`spl-token ${splToken} is not supported`)
90
+ asset = api.getTokenByAddress(splToken)
91
+ }
92
+
93
+ let reference: PublicKey[]
94
+ const referenceParams = searchParams.getAll('reference')
95
+ if (referenceParams.length) {
96
+ try {
97
+ reference = referenceParams.map((reference) => new PublicKey(reference))
98
+ } catch (error) {
99
+ throw new ParseURLError('reference invalid')
100
+ }
101
+ }
102
+
103
+ const label = searchParams.get('label') || undefined
104
+ const message = searchParams.get('message') || undefined
105
+ const memo = searchParams.get('memo') || undefined
106
+
107
+ return {
108
+ asset,
109
+ recipient: recipient.toString(),
110
+ amount: amount ? amount.toString(10) : undefined,
111
+ splToken: splToken ? splToken.toString() : undefined,
112
+ reference,
113
+ label,
114
+ message,
115
+ memo,
116
+ }
117
+ }
@@ -0,0 +1,26 @@
1
+ // @flow
2
+ import assert from 'assert'
3
+
4
+ import type { ParsedURL } from './parseURL'
5
+
6
+ export function prepareSendData(parsedData: ParsedURL, { asset, feeAmount, walletAccount }) {
7
+ const { amount: amountStr, recipient, splToken, ...options } = parsedData
8
+
9
+ assert(amountStr, 'PrepareTxError: Missing amount')
10
+ assert(recipient, 'PrepareTxError: Missing recipient')
11
+
12
+ const amount = asset.currency.defaultUnit(amountStr)
13
+
14
+ return {
15
+ asset: asset.name,
16
+ baseAsset: asset.baseAsset,
17
+ customMintAddress: splToken,
18
+ feeAmount,
19
+ receiver: {
20
+ address: recipient,
21
+ amount,
22
+ },
23
+ walletAccount,
24
+ ...options,
25
+ }
26
+ }
@@ -0,0 +1,37 @@
1
+ // @flow
2
+ import api from '..'
3
+ import NumberUnit from '@exodus/currency'
4
+
5
+ export class ValidateError extends Error {
6
+ name = 'ValidateError'
7
+ }
8
+
9
+ type PaymentRequest = {
10
+ asset: Object,
11
+ senderInfo: Object,
12
+ feeAmount: NumberUnit,
13
+ recipient: string,
14
+ amount: number,
15
+ checkEnoughBalance?: boolean,
16
+ }
17
+
18
+ export async function validateBeforePay({
19
+ asset,
20
+ senderInfo,
21
+ amount,
22
+ feeAmount = 0,
23
+ recipient,
24
+ checkEnoughBalance = true,
25
+ }: PaymentRequest) {
26
+ const isNative = asset.name === asset.baseAsset.name
27
+ const recipientInfo = await api.getAccountInfo(recipient)
28
+ if (!recipientInfo) throw new ValidateError(`recipient ${recipient} not found`)
29
+
30
+ if (checkEnoughBalance) {
31
+ const totalAmountMustPay = asset.currency.defaultUnit(amount).add(feeAmount)
32
+ const currentBalance = isNative ? senderInfo.balance : senderInfo.tokenBalances[asset.name]
33
+ if (totalAmountMustPay.gt(currentBalance)) throw new ValidateError(`insufficient funds`)
34
+ }
35
+
36
+ return true
37
+ }
@@ -4,7 +4,11 @@ import assert from 'minimalistic-assert'
4
4
 
5
5
  const DEFAULT_POOL_ADDRESS = '9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF' // Everstake
6
6
 
7
- const DEFAULT_REMOTE_CONFIG = { rpcs: [], staking: { enabled: true, pool: DEFAULT_POOL_ADDRESS } }
7
+ const DEFAULT_REMOTE_CONFIG = {
8
+ rpcs: [],
9
+ ws: [],
10
+ staking: { enabled: true, pool: DEFAULT_POOL_ADDRESS },
11
+ }
8
12
 
9
13
  export class SolanaMonitor extends BaseMonitor {
10
14
  constructor({ api, includeUnparsed = false, ...args }) {
@@ -14,11 +18,45 @@ export class SolanaMonitor extends BaseMonitor {
14
18
  this.cursors = {}
15
19
  this.assets = {}
16
20
  this.includeUnparsed = includeUnparsed
21
+ this.addHook('before-stop', (...args) => this.beforeStop(...args))
22
+ }
23
+
24
+ async beforeStop() {
25
+ const walletAccounts = await this.aci.getWalletAccounts({ assetName: this.asset.name })
26
+ return Promise.all(walletAccounts.map((walletAccount) => this.stopListener({ walletAccount })))
27
+ }
28
+
29
+ async initWalletAccount({ walletAccount }) {
30
+ if (this.tickCount[walletAccount] === 0) {
31
+ await this.startListener({ walletAccount })
32
+ }
33
+ }
34
+
35
+ async startListener({ walletAccount }) {
36
+ const address = await this.aci.getReceiveAddress({ assetName: this.asset.name, walletAccount })
37
+ return this.api.watchAddress({
38
+ address,
39
+ /*
40
+ // OPTIONAL. Relying on polling through ws
41
+ tokensAddresses: [], // needed for ASA subs
42
+ handleAccounts: (updates) => this.accountsCallback({ updates, walletAccount }),
43
+ handleTransfers: (txs) => {
44
+ // new SOL tx, ticking monitor
45
+ this.tick({ walletAccount }) // it will cause refresh for both sender/receiver. Without necessarily fetching the tx if it's not finalized in the node.
46
+ },
47
+ */
48
+ })
49
+ }
50
+
51
+ async stopListener({ walletAccount }) {
52
+ const address = await this.aci.getReceiveAddress({ assetName: this.asset.name, walletAccount })
53
+ return this.api.unwatchAddress({ address })
17
54
  }
18
55
 
19
56
  setServer(config = {}) {
20
- const { rpcs, staking = {} } = { ...DEFAULT_REMOTE_CONFIG, ...config }
57
+ const { rpcs, ws, staking = {} } = { ...DEFAULT_REMOTE_CONFIG, ...config }
21
58
  this.api.setServer(rpcs[0])
59
+ this.api.setWsEndpoint(ws[0])
22
60
  this.staking = staking
23
61
  }
24
62
 
@@ -35,6 +73,9 @@ export class SolanaMonitor extends BaseMonitor {
35
73
  }
36
74
 
37
75
  async tick({ walletAccount, refresh }) {
76
+ // Check for new wallet account
77
+ await this.initWalletAccount({ walletAccount })
78
+
38
79
  const assetName = this.asset.name
39
80
  this.assets = await this.aci.getAssetsForNetwork({ baseAssetName: assetName })
40
81
  this.api.setTokens(this.assets)