@exodus/solana-api 1.2.0 → 1.2.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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/index.js +157 -21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/solana-api",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Exodus internal Solana asset API wrapper",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -13,8 +13,9 @@
13
13
  "access": "restricted"
14
14
  },
15
15
  "dependencies": {
16
+ "@exodus/solana-lib": "^1.2.1",
16
17
  "@exodus/solana-web3.js": "^0.87.1-exodus3",
17
18
  "lodash": "^4.17.11"
18
19
  },
19
- "gitHead": "912e3574f02b1886ed0c6616a7d35f20de533dbd"
20
+ "gitHead": "51f71e5bcebd886116bf433c8fe79859a185d16c"
20
21
  }
package/src/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  // @flow
2
2
  import { Connection, PublicKey } from '@exodus/solana-web3.js'
3
+ import { tokens, SYSTEM_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@exodus/solana-lib'
4
+ import assert from 'assert'
5
+ import lodash from 'lodash'
3
6
 
4
7
  // Doc: https://solana-labs.github.io/solana-web3.js/
5
8
 
6
9
  const RPC_URL = 'https://api.mainnet-beta.solana.com' // https://solana-api.projectserum.com
7
10
 
11
+ // Tokens + SOL api support
8
12
  class Api {
9
13
  constructor(rpcUrl) {
10
14
  this.setServer(rpcUrl)
@@ -22,7 +26,11 @@ class Api {
22
26
 
23
27
  // Transaction structure: https://docs.solana.com/apps/jsonrpc-api#transaction-structure
24
28
  async getTransactionById(id: string) {
25
- return this.connection.getConfirmedTransaction(id)
29
+ const { result } = await this.connection._rpcRequest('getConfirmedTransaction', [
30
+ id,
31
+ 'jsonParsed',
32
+ ])
33
+ return result
26
34
  }
27
35
 
28
36
  async getFee(): number {
@@ -41,11 +49,11 @@ class Api {
41
49
  return this.connection.getBlockTime(slot)
42
50
  }
43
51
 
44
- async getConfirmedSignaturesForAddress(address: string, { before, until, limit } = {}): any {
52
+ async getConfirmedSignaturesForAddress(address: string, { until, before, limit } = {}): any {
45
53
  until = until || undefined
46
54
  return this.connection.getConfirmedSignaturesForAddress2(new PublicKey(address), {
47
- before,
48
55
  until,
56
+ before,
49
57
  limit,
50
58
  })
51
59
  }
@@ -53,7 +61,7 @@ class Api {
53
61
  /**
54
62
  * Get transactions from an address
55
63
  */
56
- async getTransactions(address: string, { cursor, limit } = {}): any {
64
+ async getTransactions(address: string, { cursor, before, limit, tokenTicker } = {}): any {
57
65
  let transactions = []
58
66
  // cursor is a txHash
59
67
 
@@ -62,9 +70,17 @@ class Api {
62
70
 
63
71
  const txsId = await this.getConfirmedSignaturesForAddress(address, {
64
72
  until,
73
+ before,
65
74
  limit,
66
75
  })
67
76
 
77
+ let tokenAccountsByOwner // Array
78
+ if (txsId.length) tokenAccountsByOwner = await this.getTokenAccountsByOwner(address)
79
+
80
+ if (tokenTicker) {
81
+ // TODO: eventually filter txs by token ticker (is it done at monitor level?)
82
+ }
83
+
68
84
  for (let tx of txsId) {
69
85
  // get tx details
70
86
  const [txDetails, blockTime] = await Promise.all([
@@ -74,26 +90,10 @@ class Api {
74
90
  if (txDetails === null) continue
75
91
 
76
92
  const timestamp = blockTime * 1000
77
- const from = txDetails.transaction.keys[0].toString()
78
- const to = txDetails.transaction.keys[1].toString()
79
- let { preBalances, postBalances, fee } = txDetails.meta
80
- const isSending = address === from
81
- fee = !isSending ? 0 : fee
82
- const amount = Math.abs(
83
- isSending ? preBalances[0] - postBalances[0] - fee : postBalances[1] - preBalances[1]
84
- )
85
-
86
93
  transactions.push({
87
- id: tx.signature,
88
- memo: tx.memo,
89
- slot: tx.slot,
90
94
  timestamp,
91
95
  date: new Date(timestamp),
92
- fee, // lamports
93
- from,
94
- to,
95
- amount, // lamports
96
- error: !(txDetails.meta.err === null),
96
+ ...Api.parseTransaction(address, txDetails, tokenAccountsByOwner),
97
97
  })
98
98
  }
99
99
  } catch (err) {
@@ -106,6 +106,142 @@ class Api {
106
106
  return { transactions, newCursor }
107
107
  }
108
108
 
109
+ parseTransaction(...args) {
110
+ // alias
111
+ return Api.parseTransaction(...args)
112
+ }
113
+
114
+ static parseTransaction(
115
+ ownerAddress: string,
116
+ txDetails: Object,
117
+ tokenAccountsByOwner: ?Array
118
+ ): Object {
119
+ const { fee } = txDetails.meta
120
+ let { instructions } = txDetails.transaction.message
121
+ instructions = instructions
122
+ .filter((ix) => ix.parsed) // only known instructions
123
+ .map((ix) => ({
124
+ program: ix.program, // system or spl-token
125
+ type: ix.parsed.type, // transfer, createAccount, initializeAccount
126
+ ...ix.parsed.info,
127
+ }))
128
+
129
+ const solanaTx = lodash.find(instructions, { program: 'system', type: 'transfer' }) // get SOL transfer
130
+ // program:type tells us if it's a SOL or Token transfer
131
+
132
+ let tx
133
+ if (solanaTx) {
134
+ // Solana tx
135
+ const isSending = ownerAddress === solanaTx.source
136
+ tx = {
137
+ from: solanaTx.source,
138
+ to: solanaTx.destination,
139
+ amount: solanaTx.lamports, // number
140
+ fee: isSending ? fee : 0,
141
+ }
142
+ } else {
143
+ // Token tx
144
+ assert.ok(
145
+ Array.isArray(tokenAccountsByOwner),
146
+ 'tokenAccountsByOwner is required when parsing token tx'
147
+ )
148
+ let tokenTxs = lodash
149
+ .filter(instructions, { program: 'spl-token', type: 'transfer' }) // get Token transfer: could have more than 1 instructions
150
+ .map((ix) => {
151
+ // add token details based on source/destination address
152
+ let tokenAccount = lodash.find(tokenAccountsByOwner, { tokenAccountAddress: ix.source })
153
+ const isSending = !!tokenAccount
154
+ if (!isSending)
155
+ tokenAccount = lodash.find(tokenAccountsByOwner, {
156
+ tokenAccountAddress: ix.destination,
157
+ }) // receiving
158
+ if (!tokenAccount) return null // no transfers with our addresses involved
159
+ delete tokenAccount.balance
160
+ delete tokenAccount.owner
161
+
162
+ return {
163
+ token: tokenAccount,
164
+ from: ix.source,
165
+ to: ix.destination,
166
+ amount: Number(ix.amount), // token
167
+ fee: isSending ? fee : 0, // in lamports
168
+ }
169
+ })
170
+ // .reduce to sum/sub (based on isSending) all the same tokens amount (From instructions -> 1 single tx)
171
+ tx = tokenTxs.reduce((finalTx, ix) => {
172
+ if (!ix) return finalTx // skip null instructions
173
+ if (!finalTx.token) return ix // init finalTx (support just 1 token type per tx)
174
+ if (finalTx.token.ticker === ix.token.ticker) finalTx.amount += ix.amount
175
+ return finalTx
176
+ }, {})
177
+ }
178
+
179
+ // How tokens tx are parsed:
180
+ // 0. compute incoming or outgoing tx: it's outgoing if spl-token:transfer has source/destination included in tokenAccountsByOwner
181
+ // 1. if it's a sent tx: sum all instructions amount (spl-token:transfer)
182
+ // 2. if it's an incoming tx: sull all the amounts with destination included in tokenAccountsByOwner (aggregating by ticker)
183
+ // QUESTION: How do I know what are my tokens addresses deterministically? It's not possible, gotta use tokenAccountsByOwner
184
+
185
+ return {
186
+ id: txDetails.transaction.signatures[0],
187
+ slot: txDetails.slot,
188
+ error: !(txDetails.meta.err === null),
189
+ ...tx,
190
+ }
191
+ }
192
+
193
+ async getTokenAccountsByOwner(address: string, tokenTicker: ?string): Array {
194
+ const {
195
+ result: { value: accountsList },
196
+ } = await this.connection._rpcRequest('getTokenAccountsByOwner', [
197
+ address,
198
+ { programId: TOKEN_PROGRAM_ID.toBase58() },
199
+ { encoding: 'jsonParsed' },
200
+ ])
201
+
202
+ const tokenAccounts = []
203
+ for (let entry of accountsList) {
204
+ const { pubkey, account } = entry
205
+
206
+ const token = tokens.find(
207
+ ({ mintAddress }) => mintAddress === lodash.get(account, 'data.parsed.info.mint')
208
+ )
209
+ const balance = lodash.get(account, 'data.parsed.info.tokenAmount.amount', '0')
210
+ tokenAccounts.push({
211
+ tokenAccountAddress: pubkey,
212
+ owner: address,
213
+ tokenName: token.tokenName,
214
+ ticker: token.tokenSymbol,
215
+ balance,
216
+ })
217
+ }
218
+ // eventually filter by token
219
+ return tokenTicker
220
+ ? tokenAccounts.filter(({ ticker }) => ticker === tokenTicker)
221
+ : tokenAccounts
222
+ }
223
+
224
+ async getAddressType(address: string) {
225
+ // solana, token or null (unknown), meaning address has never been initialized
226
+ const account = await this.connection.getAccountInfo(new PublicKey(address))
227
+ if (account === null) return null
228
+ return account.owner.equals(SYSTEM_PROGRAM_ID)
229
+ ? 'solana'
230
+ : account.owner.equals(TOKEN_PROGRAM_ID)
231
+ ? 'token'
232
+ : null
233
+ }
234
+
235
+ async isTokenAddress(address: string) {
236
+ const type = await this.getAddressType(address)
237
+ return type === 'token'
238
+ }
239
+
240
+ async isSOLaddress(address: string) {
241
+ const type = await this.getAddressType(address)
242
+ return type === 'solana'
243
+ }
244
+
109
245
  /**
110
246
  * Broadcast a signed transaction
111
247
  */