@exodus/solana-api 2.0.16 → 2.1.1

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.1",
4
4
  "description": "Exodus internal Solana asset API wrapper",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -14,19 +14,25 @@
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.1",
24
+ "@exodus/solana-web3.js": "1.31.0-exodus.3",
25
+ "@ungap/url-search-params": "^0.2.2",
26
+ "bignumber.js": "^9.0.1",
23
27
  "bn.js": "^4.11.0",
28
+ "debug": "^4.1.1",
24
29
  "lodash": "^4.17.11",
30
+ "tweetnacl": "^1.0.3",
25
31
  "url-join": "4.0.0",
26
32
  "wretch": "^1.5.2"
27
33
  },
28
34
  "devDependencies": {
29
35
  "node-fetch": "~2.6.0"
30
36
  },
31
- "gitHead": "9ec7084bcdfe14f1c6f11df393351885773046a6"
37
+ "gitHead": "cafda9efb741487bae7db4c3187c8b69d3553151"
32
38
  }
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
- async getFee(): number {
90
- const {
91
- value: {
92
- feeCalculator: { lamportsPerSignature },
93
- },
94
- } = await this.api.post({
95
- method: 'getRecentBlockhash',
96
- })
97
- return lamportsPerSignature
139
+ async getFee(): Promise<number> {
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
  /**
@@ -525,13 +564,8 @@ export class Api {
525
564
  }
526
565
 
527
566
  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
567
+ const result = await this.rpcCall('getTokenSupply', [mintAddress])
568
+ return lodash.get(result, 'value.amount')
535
569
  }
536
570
 
537
571
  async getWalletTokensList({ tokenAccounts }) {
@@ -556,11 +590,12 @@ export class Api {
556
590
  return tokensMint
557
591
  }
558
592
 
559
- 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
- })
593
+ async getTokenAccountsByOwner(address: string, tokenTicker: ?string): Promise<Array> {
594
+ const { value: accountsList } = await this.rpcCall(
595
+ 'getTokenAccountsByOwner',
596
+ [address, { programId: TOKEN_PROGRAM_ID.toBase58() }, { encoding: 'jsonParsed' }],
597
+ { address }
598
+ )
564
599
 
565
600
  const tokenAccounts = []
566
601
  for (let entry of accountsList) {
@@ -605,10 +640,7 @@ export class Api {
605
640
  async isAssociatedTokenAccountActive(tokenAddress: string) {
606
641
  // Returns the token balance of an SPL Token account.
607
642
  try {
608
- await this.api.post({
609
- method: 'getTokenAccountBalance',
610
- params: [tokenAddress],
611
- })
643
+ await this.rpcCall('getTokenAccountBalance', [tokenAddress])
612
644
  return true
613
645
  } catch (e) {
614
646
  return false
@@ -617,21 +649,16 @@ export class Api {
617
649
 
618
650
  // Returns account balance of a SPL Token account.
619
651
  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
652
+ const result = await this.rpcCall('getTokenAccountBalance', [tokenAddress])
653
+ return lodash.get(result, 'value.amount')
628
654
  }
629
655
 
630
656
  async getAccountInfo(address: string) {
631
- const { value } = await this.api.post({
632
- method: 'getAccountInfo',
633
- params: [address, { encoding: 'jsonParsed', commitment: 'single' }],
634
- })
657
+ const { value } = await this.rpcCall(
658
+ 'getAccountInfo',
659
+ [address, { encoding: 'jsonParsed', commitment: 'single' }],
660
+ { address }
661
+ )
635
662
  return value
636
663
  }
637
664
 
@@ -650,8 +677,8 @@ export class Api {
650
677
  }
651
678
 
652
679
  async getDecimals(tokenMintAddress: string) {
653
- const res = await this.api.post({ method: 'getTokenSupply', params: [tokenMintAddress] })
654
- return lodash.get(res, 'value.decimals', null)
680
+ const result = await this.rpcCall('getTokenSupply', [tokenMintAddress])
681
+ return lodash.get(result, 'value.decimals', null)
655
682
  }
656
683
 
657
684
  async getAddressType(address: string) {
@@ -695,24 +722,22 @@ export class Api {
695
722
  }
696
723
 
697
724
  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
- },
725
+ const params = [
726
+ STAKE_PROGRAM_ID.toBase58(),
727
+ {
728
+ filters: [
729
+ {
730
+ memcmp: {
731
+ offset: 12,
732
+ bytes: address,
710
733
  },
711
- ],
712
- encoding: 'jsonParsed',
713
- },
714
- ],
715
- })
734
+ },
735
+ ],
736
+ encoding: 'jsonParsed',
737
+ },
738
+ ]
739
+ const res = await this.rpcCall('getProgramAccounts', params, { address })
740
+
716
741
  const accounts = {}
717
742
  let totalStake = 0
718
743
  let locked = 0
@@ -761,29 +786,15 @@ export class Api {
761
786
  }
762
787
 
763
788
  async getMinimumBalanceForRentExemption(size: number) {
764
- const minimumBalance = await this.api.post({
765
- method: 'getMinimumBalanceForRentExemption',
766
- params: [size],
767
- })
768
-
769
- return minimumBalance
789
+ return this.rpcCall('getMinimumBalanceForRentExemption', [size])
770
790
  }
771
791
 
772
792
  async getProgramAccounts(programId: string, config) {
773
- const response = await this.api.post({
774
- method: 'getProgramAccounts',
775
- params: [programId, config],
776
- })
777
-
778
- return response
793
+ return this.rpcCall('getProgramAccounts', [programId, config])
779
794
  }
780
795
 
781
796
  async getMultipleAccounts(pubkeys: string[], config) {
782
- const response = await this.api.post({
783
- method: 'getMultipleAccounts',
784
- params: [pubkeys, config],
785
- })
786
-
797
+ const response = await this.rpcCall('getMultipleAccounts', [pubkeys, config])
787
798
  return response && response.value ? response.value : []
788
799
  }
789
800
 
@@ -793,10 +804,8 @@ export class Api {
793
804
  broadcastTransaction = async (signedTx: string): string => {
794
805
  console.log('Solana broadcasting TX:', signedTx) // base64
795
806
 
796
- const result = await this.api.post({
797
- method: 'sendTransaction',
798
- params: [signedTx, { encoding: 'base64', commitment: 'singleGossip' }],
799
- })
807
+ const params = [signedTx, { encoding: 'base64', commitment: 'finalized' }]
808
+ const result = await this.rpcCall('sendTransaction', params, { forceHttp: true })
800
809
 
801
810
  console.log(`tx ${JSON.stringify(result)} sent!`)
802
811
  return result || null
@@ -805,10 +814,7 @@ export class Api {
805
814
  simulateTransaction = async (encodedTransaction, options) => {
806
815
  const {
807
816
  value: { accounts },
808
- } = await this.api.post({
809
- method: 'simulateTransaction',
810
- params: [encodedTransaction, options],
811
- })
817
+ } = await this.rpcCall('simulateTransaction', [encodedTransaction, options])
812
818
 
813
819
  return accounts
814
820
  }
@@ -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,139 @@
1
+ // @flow
2
+ import nacl from 'tweetnacl'
3
+ import { fetchival } from '@exodus/fetch'
4
+ import ms from 'ms'
5
+ import { SystemInstruction, Transaction, PublicKey } from '@exodus/solana-web3.js'
6
+ import api from '..'
7
+ import BigNumber from 'bignumber.js'
8
+ import {
9
+ TOKEN_PROGRAM_ID,
10
+ decodeTokenProgramInstruction,
11
+ SYSTEM_PROGRAM_ID,
12
+ } from '@exodus/solana-lib'
13
+
14
+ export class FetchTransactionError extends Error {
15
+ name = 'FetchTransactionError'
16
+ }
17
+
18
+ export class ParseTransactionError extends Error {
19
+ name = 'ParseTransactionError'
20
+ }
21
+
22
+ const isTransferCheckedInstruction = (decodedInstruction) =>
23
+ decodedInstruction.type === 'transferChecked'
24
+ const isTransferInstruction = (decodedInstruction) => decodedInstruction.type === 'transfer'
25
+ const isSplAccount = (account) =>
26
+ account && account.owner === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
27
+
28
+ export async function fetchTransaction({
29
+ account,
30
+ link,
31
+ commitment,
32
+ }: {
33
+ account: string,
34
+ link: string | URL,
35
+ commitment?: string,
36
+ }) {
37
+ const response = await fetchival(String(link), {
38
+ mode: 'cors',
39
+ cache: 'no-cache',
40
+ credentials: 'omit',
41
+ timeout: ms('10s'),
42
+ headers: {
43
+ Accept: 'application/json',
44
+ 'Content-Type': 'application/json',
45
+ },
46
+ }).post({ account })
47
+ if (!response || !response.transaction) throw new FetchTransactionError('missing transaction')
48
+ const { transaction: txString } = response
49
+ if (typeof txString !== 'string') throw new FetchTransactionError('invalid transaction')
50
+ const transaction = Transaction.from(Buffer.from(txString, 'base64'))
51
+
52
+ const { signatures, feePayer, recentBlockhash } = transaction
53
+ if (signatures.length) {
54
+ if (!feePayer) throw new FetchTransactionError('missing fee payer')
55
+ if (!feePayer.equals(signatures[0].publicKey))
56
+ throw new FetchTransactionError('invalid fee payer')
57
+ if (!recentBlockhash) throw new FetchTransactionError('missing recent blockhash')
58
+
59
+ // A valid signature for everything except `account` must be provided.
60
+ const message = transaction.serializeMessage()
61
+ for (const { signature, publicKey } of signatures) {
62
+ if (signature) {
63
+ if (!nacl.sign.detached.verify(message, signature, publicKey.toBuffer()))
64
+ throw new FetchTransactionError('invalid signature')
65
+ } else if (publicKey.equals(new PublicKey(account))) {
66
+ // If the only signature expected is for `account`, ignore the recent blockhash in the transaction.
67
+ if (signatures.length === 1) {
68
+ transaction.recentBlockhash = await api.getRecentBlockHash(commitment)
69
+ }
70
+ } else {
71
+ throw new FetchTransactionError('missing signature')
72
+ }
73
+ }
74
+ } else {
75
+ // Ignore the fee payer and recent blockhash in the transaction and initialize them.
76
+ transaction.feePayer = account
77
+ transaction.recentBlockhash = await api.getRecentBlockHash(commitment)
78
+ }
79
+
80
+ return parseInstructions(transaction)
81
+ }
82
+
83
+ async function parseInstructions(transaction: Transaction) {
84
+ // Make a copy of the instructions we're going to mutate it.
85
+ const instructions = transaction.instructions.slice()
86
+
87
+ if (!instructions || !Array.isArray(instructions) || instructions.length !== 1)
88
+ throw new ParseTransactionError('Invalid transaction instructions')
89
+
90
+ // Transfer instruction must be the last instruction
91
+ const instruction = instructions.pop()
92
+ if (!instruction) throw new ParseTransactionError('missing transfer instruction')
93
+
94
+ const isTokenTransfer = instruction.programId.equals(TOKEN_PROGRAM_ID)
95
+ const isSolNativeTransfer = instruction.programId.equals(SYSTEM_PROGRAM_ID)
96
+
97
+ if (isTokenTransfer) {
98
+ const decodedInstruction = decodeTokenProgramInstruction(instruction)
99
+ if (
100
+ !isTransferCheckedInstruction(decodedInstruction) &&
101
+ !isTransferInstruction(decodedInstruction)
102
+ )
103
+ throw new ParseTransactionError('invalid token transfer')
104
+
105
+ const [, mint, destination, owner] = instruction.keys
106
+
107
+ const splToken = mint.pubkey.toBase58()
108
+ let asset
109
+ let recipient = destination.pubkey.toBase58()
110
+ if (splToken) {
111
+ if (!api.isTokenSupported(splToken))
112
+ throw new ParseTransactionError(`spl-token ${splToken} is not supported`)
113
+ asset = api.getTokenByAddress(splToken)
114
+
115
+ const account = await api.getAccountInfo(recipient)
116
+ if (isSplAccount(account)) recipient = account.data.parsed.info.owner
117
+ }
118
+
119
+ return {
120
+ amount: asset.currency
121
+ .baseUnit(new BigNumber(decodedInstruction.data.amount).toString())
122
+ .toDefaultString(),
123
+ decimals: decodedInstruction.data.decimals,
124
+ recipient,
125
+ sender: owner.pubkey.toBase58(),
126
+ splToken,
127
+ asset,
128
+ }
129
+ } else if (isSolNativeTransfer) {
130
+ const decodedTransaction = SystemInstruction.decodeTransfer(instruction)
131
+ return {
132
+ sender: decodedTransaction.fromPubkey.toString(),
133
+ amount: decodedTransaction.lamports.toString(),
134
+ recipient: decodedTransaction.toPubkey.toString(),
135
+ }
136
+ }
137
+
138
+ throw new Error('Invalid transfer instruction')
139
+ }
@@ -0,0 +1,4 @@
1
+ export * from './parseURL.js'
2
+ export * from './prepareSendData.js'
3
+ export * from './validateBeforePay'
4
+ export * from './fetchTransaction'
@@ -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)