@cityofzion/bs-ethereum 0.7.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/jest.config.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { JestConfigWithTsJest } from 'ts-jest'
2
+ const config: JestConfigWithTsJest = {
3
+ preset: 'ts-jest',
4
+ testEnvironment: 'node',
5
+ clearMocks: true,
6
+ verbose: true,
7
+ bail: true,
8
+ testMatch: ['<rootDir>/**/*.spec.ts'],
9
+ setupFiles: ['<rootDir>/jest.setup.ts'],
10
+ detectOpenHandles: true,
11
+ }
12
+
13
+ export default config
package/jest.setup.ts ADDED
@@ -0,0 +1 @@
1
+ import 'dotenv/config'
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@cityofzion/bs-ethereum",
3
+ "version": "0.7.0",
4
+ "main": "dist/index.js",
5
+ "types": "dist/index.d.ts",
6
+ "repository": "https://github.com/CityOfZion/blockchain-services",
7
+ "author": "Coz",
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "build": "tsc --project tsconfig.build.json",
11
+ "test": "jest --config jest.config.ts"
12
+ },
13
+ "dependencies": {
14
+ "@cityofzion/blockchain-service": "0.7.0",
15
+ "ethers": "5.7.2",
16
+ "@urql/core": "~4.1.1",
17
+ "graphql": "~16.8.0",
18
+ "node-fetch": "2.6.4",
19
+ "dayjs": "~1.11.9",
20
+ "query-string": "7.1.3",
21
+ "@ethersproject/json-wallets": "5.7.0",
22
+ "@ethersproject/bytes": "5.7.0",
23
+ "@ethersproject/bignumber": "5.7.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node-fetch": "2.6.4",
27
+ "ts-node": "10.9.1",
28
+ "typescript": "4.9.5",
29
+ "jest": "29.6.2",
30
+ "ts-jest": "29.1.1",
31
+ "@types/jest": "29.5.3",
32
+ "dotenv": "16.3.1"
33
+ }
34
+ }
@@ -0,0 +1,184 @@
1
+ import {
2
+ Account,
3
+ AccountWithDerivationPath,
4
+ BSCalculableFee,
5
+ BSWithNameService,
6
+ BSWithNft,
7
+ BlockchainDataService,
8
+ BlockchainService,
9
+ ExchangeDataService,
10
+ Network,
11
+ NftDataService,
12
+ PartialBy,
13
+ Token,
14
+ TransferParam,
15
+ } from '@cityofzion/blockchain-service'
16
+ import { ethers } from 'ethers'
17
+ import * as ethersJsonWallets from '@ethersproject/json-wallets'
18
+ import * as ethersBytes from '@ethersproject/bytes'
19
+ import * as ethersBigNumber from '@ethersproject/bignumber'
20
+ import { DEFAULT_URL_BY_NETWORK_TYPE, DERIVATION_PATH, NATIVE_ASSETS, TOKENS } from './constants'
21
+ import { BitqueryEDSEthereum } from './BitqueryEDSEthereum'
22
+ import { GhostMarketNDSEthereum } from './GhostMarketNDSEthereum'
23
+ import { RpcBDSEthereum } from './RpcBDSEthereum'
24
+ import { BitqueryBDSEthereum } from './BitqueryBDSEthereum'
25
+
26
+ export class BSEthereum<BSCustomName extends string = string>
27
+ implements BlockchainService, BSWithNft, BSWithNameService, BSCalculableFee
28
+ {
29
+ readonly blockchainName: BSCustomName
30
+ readonly feeToken: Token
31
+ readonly derivationPath: string
32
+
33
+ blockchainDataService!: BlockchainDataService
34
+ exchangeDataService!: ExchangeDataService
35
+ tokens: Token[]
36
+ nftDataService!: NftDataService
37
+ network!: Network
38
+
39
+ constructor(blockchainName: BSCustomName, network: PartialBy<Network, 'url'>) {
40
+ this.blockchainName = blockchainName
41
+ this.derivationPath = DERIVATION_PATH
42
+ this.tokens = TOKENS[network.type]
43
+
44
+ this.feeToken = this.tokens.find(token => token.symbol === 'ETH')!
45
+ this.setNetwork(network)
46
+ }
47
+
48
+ setNetwork(param: PartialBy<Network, 'url'>) {
49
+ const network = {
50
+ type: param.type,
51
+ url: param.url ?? DEFAULT_URL_BY_NETWORK_TYPE[param.type],
52
+ }
53
+ this.network = network
54
+
55
+ if (network.type === 'custom') {
56
+ this.blockchainDataService = new RpcBDSEthereum(network)
57
+ } else {
58
+ this.blockchainDataService = new BitqueryBDSEthereum(network)
59
+ }
60
+
61
+ this.exchangeDataService = new BitqueryEDSEthereum(network.type)
62
+ this.nftDataService = new GhostMarketNDSEthereum(network.type)
63
+ }
64
+
65
+ validateAddress(address: string): boolean {
66
+ return ethers.utils.isAddress(address)
67
+ }
68
+
69
+ validateEncrypted(json: string): boolean {
70
+ return ethersJsonWallets.isCrowdsaleWallet(json) || ethersJsonWallets.isKeystoreWallet(json)
71
+ }
72
+
73
+ validateKey(key: string): boolean {
74
+ try {
75
+ if (!key.startsWith('0x')) {
76
+ key = '0x' + key
77
+ }
78
+ if (ethersBytes.hexDataLength(key) !== 32) return false
79
+
80
+ return true
81
+ } catch (error) {
82
+ return false
83
+ }
84
+ }
85
+
86
+ validateNameServiceDomainFormat(domainName: string): boolean {
87
+ if (!domainName.endsWith('.eth')) return false
88
+ return true
89
+ }
90
+
91
+ generateAccountFromMnemonic(mnemonic: string[] | string, index: number): AccountWithDerivationPath {
92
+ const path = this.derivationPath.replace('?', index.toString())
93
+ const wallet = ethers.Wallet.fromMnemonic(Array.isArray(mnemonic) ? mnemonic.join(' ') : mnemonic, path)
94
+
95
+ return {
96
+ address: wallet.address,
97
+ key: wallet.privateKey,
98
+ type: 'privateKey',
99
+ derivationPath: path,
100
+ }
101
+ }
102
+
103
+ generateAccountFromKey(key: string): Account {
104
+ const wallet = new ethers.Wallet(key)
105
+ return {
106
+ address: wallet.address,
107
+ key,
108
+ type: 'privateKey',
109
+ }
110
+ }
111
+
112
+ async decrypt(json: string, password: string): Promise<Account> {
113
+ const wallet = await ethers.Wallet.fromEncryptedJson(json, password)
114
+ return {
115
+ address: wallet.address,
116
+ key: wallet.privateKey,
117
+ type: 'privateKey',
118
+ }
119
+ }
120
+
121
+ async transfer({ senderAccount, intent }: TransferParam): Promise<string> {
122
+ const provider = new ethers.providers.JsonRpcProvider(this.network.url)
123
+ const wallet = new ethers.Wallet(senderAccount.key, provider)
124
+
125
+ let transaction: ethers.providers.TransactionResponse
126
+ const decimals = intent.tokenDecimals ?? 18
127
+ const amount = ethersBigNumber.parseFixed(intent.amount, decimals)
128
+
129
+ const isNative = NATIVE_ASSETS.some(asset => asset.hash === intent.tokenHash)
130
+ if (!isNative) {
131
+ const contract = new ethers.Contract(
132
+ intent.tokenHash,
133
+ ['function transfer(address to, uint amount) returns (bool)'],
134
+ wallet
135
+ )
136
+ transaction = await contract.transfer(intent.receiverAddress, amount)
137
+ } else {
138
+ transaction = await wallet.sendTransaction({
139
+ to: intent.receiverAddress,
140
+ value: amount,
141
+ })
142
+ }
143
+
144
+ const transactionMined = await transaction.wait()
145
+ if (!transactionMined) throw new Error('Transaction not mined')
146
+
147
+ return transactionMined.transactionHash
148
+ }
149
+
150
+ async calculateTransferFee({ senderAccount, intent }: TransferParam, details?: boolean | undefined): Promise<string> {
151
+ const provider = new ethers.providers.JsonRpcProvider(this.network.url)
152
+ const wallet = new ethers.Wallet(senderAccount.key, provider)
153
+
154
+ let estimated: ethers.BigNumber
155
+
156
+ const isNative = NATIVE_ASSETS.some(asset => asset.hash === intent.tokenHash)
157
+ const decimals = intent.tokenDecimals ?? 18
158
+ const amount = ethersBigNumber.parseFixed(intent.amount, decimals)
159
+
160
+ if (!isNative) {
161
+ const contract = new ethers.Contract(
162
+ intent.tokenHash,
163
+ ['function transfer(address to, uint amount) returns (bool)'],
164
+ wallet
165
+ )
166
+
167
+ estimated = await contract.estimateGas.transfer(intent.receiverAddress, amount)
168
+ } else {
169
+ estimated = await wallet.estimateGas({
170
+ to: intent.receiverAddress,
171
+ value: amount,
172
+ })
173
+ }
174
+
175
+ return ethers.utils.formatEther(estimated)
176
+ }
177
+
178
+ async resolveNameServiceDomain(domainName: string): Promise<string> {
179
+ const provider = new ethers.providers.JsonRpcProvider(this.network.url)
180
+ const address = await provider.resolveName(domainName)
181
+ if (!address) throw new Error('No address found for domain name')
182
+ return address
183
+ }
184
+ }
@@ -0,0 +1,217 @@
1
+ import {
2
+ BalanceResponse,
3
+ ContractResponse,
4
+ NetworkType,
5
+ Token,
6
+ TransactionsByAddressParams,
7
+ TransactionsByAddressResponse,
8
+ TransactionResponse,
9
+ TransactionTransferAsset,
10
+ TransactionTransferNft,
11
+ Network,
12
+ } from '@cityofzion/blockchain-service'
13
+ import { Client, cacheExchange, fetchExchange, gql } from '@urql/core'
14
+ import fetch from 'node-fetch'
15
+ import { BITQUERY_API_KEY, BITQUERY_NETWORK_BY_NETWORK_TYPE, BITQUERY_URL, TOKENS } from './constants'
16
+ import {
17
+ BitqueryTransaction,
18
+ bitqueryGetBalanceQuery,
19
+ bitqueryGetTokenInfoQuery,
20
+ bitqueryGetTransactionQuery,
21
+ bitqueryGetTransactionsByAddressQuery,
22
+ } from './graphql'
23
+ import { RpcBDSEthereum } from './RpcBDSEthereum'
24
+
25
+ export class BitqueryBDSEthereum extends RpcBDSEthereum {
26
+ private readonly client: Client
27
+ private readonly networkType: Exclude<NetworkType, 'custom'>
28
+
29
+ constructor(network: Network) {
30
+ super(network)
31
+
32
+ if (network.type === 'custom') throw new Error('Custom network not supported')
33
+ this.networkType = network.type
34
+
35
+ this.client = new Client({
36
+ url: BITQUERY_URL,
37
+ exchanges: [cacheExchange, fetchExchange],
38
+ fetch,
39
+ fetchOptions: {
40
+ headers: {
41
+ 'X-API-KEY': BITQUERY_API_KEY,
42
+ },
43
+ },
44
+ })
45
+ }
46
+
47
+ async getTransaction(hash: string): Promise<TransactionResponse> {
48
+ const result = await this.client
49
+ .query(bitqueryGetTransactionQuery, {
50
+ hash,
51
+ network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType],
52
+ })
53
+ .toPromise()
54
+ if (result.error) throw new Error(result.error.message)
55
+ if (!result.data || !result.data.ethereum.transfers.length) throw new Error('Transaction not found')
56
+
57
+ const transfers = result.data.ethereum.transfers.map(this.parseTransactionTransfer)
58
+
59
+ const {
60
+ block: {
61
+ height,
62
+ timestamp: { unixtime },
63
+ },
64
+ transaction: { gasValue, hash: transactionHash },
65
+ } = result.data.ethereum.transfers[0]
66
+
67
+ return {
68
+ block: height,
69
+ time: unixtime,
70
+ hash: transactionHash,
71
+ fee: String(gasValue),
72
+ transfers,
73
+ notifications: [],
74
+ }
75
+ }
76
+
77
+ async getTransactionsByAddress({
78
+ address,
79
+ page = 1,
80
+ }: TransactionsByAddressParams): Promise<TransactionsByAddressResponse> {
81
+ const limit = 10
82
+ const offset = limit * (page - 1)
83
+
84
+ const result = await this.client
85
+ .query(bitqueryGetTransactionsByAddressQuery, {
86
+ address,
87
+ limit,
88
+ offset,
89
+ network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType],
90
+ })
91
+ .toPromise()
92
+
93
+ if (result.error) throw new Error(result.error.message)
94
+ if (!result.data) throw new Error('Address does not have transactions')
95
+
96
+ const totalCount = (result.data.ethereum.sentCount.count ?? 0) + (result.data.ethereum.receiverCount.count ?? 0)
97
+ const mixedTransfers = [...(result?.data?.ethereum?.sent ?? []), ...(result?.data?.ethereum?.received ?? [])]
98
+
99
+ const transactions = new Map<string, TransactionResponse>()
100
+
101
+ mixedTransfers.forEach(transfer => {
102
+ const transactionTransfer = this.parseTransactionTransfer(transfer)
103
+
104
+ const existingTransaction = transactions.get(transfer.transaction.hash)
105
+ if (existingTransaction) {
106
+ existingTransaction.transfers.push(transactionTransfer)
107
+ return
108
+ }
109
+
110
+ transactions.set(transfer.transaction.hash, {
111
+ block: transfer.block.height,
112
+ hash: transfer.transaction.hash,
113
+ time: transfer.block.timestamp.unixtime,
114
+ fee: String(transfer.transaction.gasValue),
115
+ transfers: [transactionTransfer],
116
+ notifications: [],
117
+ })
118
+ })
119
+
120
+ return {
121
+ totalCount,
122
+ limit: limit * 2,
123
+ transactions: Array.from(transactions.values()),
124
+ }
125
+ }
126
+
127
+ async getContract(): Promise<ContractResponse> {
128
+ throw new Error("Bitquery doesn't support contract info")
129
+ }
130
+
131
+ async getTokenInfo(hash: string): Promise<Token> {
132
+ const localToken = TOKENS[this.networkType].find(token => token.hash === hash)
133
+ if (localToken) return localToken
134
+
135
+ const result = await this.client
136
+ .query(bitqueryGetTokenInfoQuery, {
137
+ hash,
138
+ network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType],
139
+ })
140
+ .toPromise()
141
+
142
+ if (result.error) throw new Error(result.error.message)
143
+ if (!result.data || result.data.ethereum.smartContractCalls.length <= 0) throw new Error('Token not found')
144
+
145
+ const {
146
+ address: { address },
147
+ currency: { decimals, name, symbol, tokenType },
148
+ } = result.data.ethereum.smartContractCalls[0].smartContract
149
+
150
+ if (tokenType !== 'ERC20') throw new Error('Token is not ERC20')
151
+
152
+ return {
153
+ hash: address,
154
+ name,
155
+ symbol,
156
+ decimals,
157
+ }
158
+ }
159
+
160
+ async getBalance(address: string): Promise<BalanceResponse[]> {
161
+ const result = await this.client
162
+ .query(bitqueryGetBalanceQuery, {
163
+ address,
164
+ network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType],
165
+ })
166
+ .toPromise()
167
+
168
+ if (result.error) throw new Error(result.error.message)
169
+ const data = result.data?.ethereum.address[0].balances ?? []
170
+
171
+ const balances = data.map(
172
+ ({ value, currency: { address, decimals, name, symbol } }): BalanceResponse => ({
173
+ amount: value.toString(),
174
+ token: {
175
+ hash: address,
176
+ symbol,
177
+ name,
178
+ decimals,
179
+ },
180
+ })
181
+ )
182
+
183
+ return balances
184
+ }
185
+
186
+ private parseTransactionTransfer({
187
+ amount,
188
+ currency: { tokenType, address, decimals, name, symbol },
189
+ entityId,
190
+ sender,
191
+ receiver,
192
+ }: BitqueryTransaction): TransactionTransferAsset | TransactionTransferNft {
193
+ if (tokenType === 'ERC721') {
194
+ return {
195
+ from: sender.address,
196
+ to: receiver.address,
197
+ tokenId: entityId,
198
+ contractHash: address,
199
+ type: 'nft',
200
+ }
201
+ }
202
+
203
+ return {
204
+ from: sender.address,
205
+ to: receiver.address,
206
+ contractHash: address,
207
+ amount: amount.toString(),
208
+ token: {
209
+ decimals: decimals,
210
+ hash: address,
211
+ name: name,
212
+ symbol: symbol,
213
+ },
214
+ type: 'token',
215
+ }
216
+ }
217
+ }
@@ -0,0 +1,66 @@
1
+ import { Currency, ExchangeDataService, NetworkType, TokenPricesResponse } from '@cityofzion/blockchain-service'
2
+ import { Client, cacheExchange, fetchExchange, gql } from '@urql/core'
3
+ import fetch from 'node-fetch'
4
+ import { BITQUERY_API_KEY, BITQUERY_URL } from './constants'
5
+ import dayjs from 'dayjs'
6
+ import utc from 'dayjs/plugin/utc'
7
+ import { bitqueryGetPricesQuery } from './graphql'
8
+
9
+ dayjs.extend(utc)
10
+ export class BitqueryEDSEthereum implements ExchangeDataService {
11
+ private readonly client: Client
12
+ private readonly networkType: NetworkType
13
+
14
+ constructor(networkType: NetworkType) {
15
+ this.networkType = networkType
16
+
17
+ this.client = new Client({
18
+ url: BITQUERY_URL,
19
+ exchanges: [cacheExchange, fetchExchange],
20
+ fetch,
21
+ fetchOptions: {
22
+ headers: {
23
+ 'X-API-KEY': BITQUERY_API_KEY,
24
+ },
25
+ },
26
+ })
27
+ }
28
+
29
+ async getTokenPrices(currency: Currency): Promise<TokenPricesResponse[]> {
30
+ if (this.networkType !== 'mainnet') throw new Error('Exchange is only available on mainnet')
31
+
32
+ const twoDaysAgo = dayjs.utc().subtract(2, 'day').startOf('date').toISOString()
33
+
34
+ const result = await this.client
35
+ .query(bitqueryGetPricesQuery, { after: twoDaysAgo, network: 'ethereum' })
36
+ .toPromise()
37
+ if (result.error) {
38
+ throw new Error(result.error.message)
39
+ }
40
+ if (!result.data) {
41
+ throw new Error('There is no price data')
42
+ }
43
+
44
+ let currencyRatio: number = 1
45
+ if (currency !== 'USD') {
46
+ currencyRatio = await this.getCurrencyRatio(currency)
47
+ }
48
+
49
+ const prices = result.data.ethereum.dexTrades.map(
50
+ (trade): TokenPricesResponse => ({
51
+ symbol: trade.baseCurrency.symbol,
52
+ price: trade.quotePrice * currencyRatio,
53
+ })
54
+ )
55
+
56
+ return prices
57
+ }
58
+
59
+ private async getCurrencyRatio(currency: Currency): Promise<number> {
60
+ const request = await fetch(`https://api.flamingo.finance/fiat/exchange-rate?pair=USD_${currency}`, {
61
+ method: 'GET',
62
+ })
63
+ const data = await request.json()
64
+ return data
65
+ }
66
+ }
@@ -0,0 +1,122 @@
1
+ import {
2
+ BlockchainService,
3
+ NftResponse,
4
+ NftsResponse,
5
+ NetworkType,
6
+ NftDataService,
7
+ GetNftParam,
8
+ GetNftsByAddressParams,
9
+ } from '@cityofzion/blockchain-service'
10
+ import qs from 'query-string'
11
+
12
+ import { GHOSTMARKET_CHAIN_BY_NETWORK_TYPE, GHOSTMARKET_URL_BY_NETWORK_TYPE } from './constants'
13
+ import fetch from 'node-fetch'
14
+
15
+ type GhostMarketNFT = {
16
+ tokenId: string
17
+ contract: {
18
+ chain?: string
19
+ hash: string
20
+ symbol: string
21
+ }
22
+ creator: {
23
+ address?: string
24
+ offchainName?: string
25
+ }
26
+ apiUrl?: string
27
+ ownerships: {
28
+ owner: {
29
+ address?: string
30
+ }
31
+ }[]
32
+ collection: {
33
+ name?: string
34
+ logoUrl?: string
35
+ }
36
+ metadata: {
37
+ description: string
38
+ mediaType: string
39
+ mediaUri: string
40
+ mintDate: number
41
+ mintNumber: number
42
+ name: string
43
+ }
44
+ }
45
+
46
+ type GhostMarketAssets = {
47
+ assets: GhostMarketNFT[]
48
+ next: string
49
+ }
50
+ export class GhostMarketNDSEthereum implements NftDataService {
51
+ private networkType: NetworkType
52
+
53
+ constructor(networkType: NetworkType) {
54
+ this.networkType = networkType
55
+ }
56
+
57
+ async getNftsByAddress({ address, size = 18, cursor, page }: GetNftsByAddressParams): Promise<NftsResponse> {
58
+ const url = this.getUrlWithParams({
59
+ size,
60
+ owners: [address],
61
+ cursor: cursor,
62
+ })
63
+
64
+ const request = await fetch(url, { method: 'GET' })
65
+ const data = (await request.json()) as GhostMarketAssets
66
+ const nfts = data.assets ?? []
67
+
68
+ return { nextCursor: data.next, items: nfts.map(this.parse.bind(this)) }
69
+ }
70
+
71
+ async getNft({ contractHash, tokenId }: GetNftParam): Promise<NftResponse> {
72
+ const url = this.getUrlWithParams({
73
+ contract: contractHash,
74
+ tokenIds: [tokenId],
75
+ })
76
+
77
+ const request = await fetch(url, { method: 'GET' })
78
+ const data = (await request.json()) as GhostMarketAssets
79
+
80
+ return this.parse(data.assets[0])
81
+ }
82
+
83
+ private treatGhostMarketImage(srcImage?: string) {
84
+ if (!srcImage) {
85
+ return
86
+ }
87
+
88
+ if (srcImage.startsWith('ipfs://')) {
89
+ const [, imageId] = srcImage.split('://')
90
+
91
+ return `https://ghostmarket.mypinata.cloud/ipfs/${imageId}`
92
+ }
93
+
94
+ return srcImage
95
+ }
96
+
97
+ private getUrlWithParams(params: any) {
98
+ const parameters = qs.stringify(
99
+ {
100
+ chain: GHOSTMARKET_CHAIN_BY_NETWORK_TYPE[this.networkType],
101
+ ...params,
102
+ },
103
+ { arrayFormat: 'bracket' }
104
+ )
105
+ return `${GHOSTMARKET_URL_BY_NETWORK_TYPE[this.networkType]}/assets?${parameters}`
106
+ }
107
+
108
+ private parse(data: GhostMarketNFT) {
109
+ const nftResponse: NftResponse = {
110
+ collectionImage: this.treatGhostMarketImage(data.collection?.logoUrl),
111
+ id: data.tokenId,
112
+ contractHash: data.contract.hash,
113
+ symbol: data.contract.symbol,
114
+ collectionName: data.collection?.name,
115
+ image: this.treatGhostMarketImage(data.metadata.mediaUri),
116
+ isSVG: String(data.metadata.mediaType).includes('svg+xml'),
117
+ name: data.metadata.name,
118
+ }
119
+
120
+ return nftResponse
121
+ }
122
+ }