@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 +10 -4
- package/src/api.js +120 -114
- package/src/connection.js +237 -0
- package/src/index.js +1 -0
- package/src/pay/fetchTransaction.js +139 -0
- package/src/pay/index.js +4 -0
- package/src/pay/parseURL.js +117 -0
- package/src/pay/prepareSendData.js +26 -0
- package/src/pay/validateBeforePay.js +37 -0
- package/src/tx-log/solana-monitor.js +43 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exodus/solana-api",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
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": "
|
|
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.
|
|
109
|
+
return !!this.getTokenByAddress(mint)
|
|
54
110
|
}
|
|
55
111
|
|
|
56
112
|
async getEpochInfo(): number {
|
|
57
|
-
const { epoch } = await this.
|
|
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.
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
102
|
-
|
|
103
|
-
params: [address],
|
|
147
|
+
const result = await this.rpcCall('getBalance', [address, { encoding: 'jsonParsed' }], {
|
|
148
|
+
address,
|
|
104
149
|
})
|
|
105
|
-
return
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
561
|
-
|
|
562
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
654
|
-
return lodash.get(
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
797
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/pay/index.js
ADDED
|
@@ -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 = {
|
|
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)
|