diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index 5508fd3..0ee646b 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -417,9 +417,9 @@ } }, "@babel/parser": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.13.tgz", - "integrity": "sha512-3l6+4YOvc9wx7VlCSw4yQfcBo01ECA8TicQfbnCPuCEpRQrf+gTUyGdxNw+pyTUyywp6JRD1w0YQs9TpBXYlkw==" + "version": "7.22.14", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.14.tgz", + "integrity": "sha512-1KucTHgOvaw/LzCVrEOAyXkr9rQlp0A1HiHRYnSUE9dmb8PvPW7o5sscg+5169r54n3vGlbx6GevTE/Iw/P3AQ==" }, "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { "version": "7.22.5", @@ -1230,9 +1230,9 @@ } }, "@babel/preset-env": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.10.tgz", - "integrity": "sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==", + "version": "7.22.14", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.14.tgz", + "integrity": "sha512-daodMIoVo+ol/g+//c/AH+szBkFj4STQUikvBijRGL72Ph+w+AMTSh55DUETe8KJlPlDT1k/mp7NBfOuiWmoig==", "requires": { "@babel/compat-data": "^7.22.9", "@babel/helper-compilation-targets": "^7.22.10", @@ -1260,41 +1260,41 @@ "@babel/plugin-syntax-top-level-await": "^7.14.5", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.22.5", - "@babel/plugin-transform-async-generator-functions": "^7.22.10", + "@babel/plugin-transform-async-generator-functions": "^7.22.11", "@babel/plugin-transform-async-to-generator": "^7.22.5", "@babel/plugin-transform-block-scoped-functions": "^7.22.5", "@babel/plugin-transform-block-scoping": "^7.22.10", "@babel/plugin-transform-class-properties": "^7.22.5", - "@babel/plugin-transform-class-static-block": "^7.22.5", + "@babel/plugin-transform-class-static-block": "^7.22.11", "@babel/plugin-transform-classes": "^7.22.6", "@babel/plugin-transform-computed-properties": "^7.22.5", "@babel/plugin-transform-destructuring": "^7.22.10", "@babel/plugin-transform-dotall-regex": "^7.22.5", "@babel/plugin-transform-duplicate-keys": "^7.22.5", - "@babel/plugin-transform-dynamic-import": "^7.22.5", + "@babel/plugin-transform-dynamic-import": "^7.22.11", "@babel/plugin-transform-exponentiation-operator": "^7.22.5", - "@babel/plugin-transform-export-namespace-from": "^7.22.5", + "@babel/plugin-transform-export-namespace-from": "^7.22.11", "@babel/plugin-transform-for-of": "^7.22.5", "@babel/plugin-transform-function-name": "^7.22.5", - "@babel/plugin-transform-json-strings": "^7.22.5", + "@babel/plugin-transform-json-strings": "^7.22.11", "@babel/plugin-transform-literals": "^7.22.5", - "@babel/plugin-transform-logical-assignment-operators": "^7.22.5", + "@babel/plugin-transform-logical-assignment-operators": "^7.22.11", "@babel/plugin-transform-member-expression-literals": "^7.22.5", "@babel/plugin-transform-modules-amd": "^7.22.5", - "@babel/plugin-transform-modules-commonjs": "^7.22.5", - "@babel/plugin-transform-modules-systemjs": "^7.22.5", + "@babel/plugin-transform-modules-commonjs": "^7.22.11", + "@babel/plugin-transform-modules-systemjs": "^7.22.11", "@babel/plugin-transform-modules-umd": "^7.22.5", "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", "@babel/plugin-transform-new-target": "^7.22.5", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5", - "@babel/plugin-transform-numeric-separator": "^7.22.5", - "@babel/plugin-transform-object-rest-spread": "^7.22.5", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.11", + "@babel/plugin-transform-numeric-separator": "^7.22.11", + "@babel/plugin-transform-object-rest-spread": "^7.22.11", "@babel/plugin-transform-object-super": "^7.22.5", - "@babel/plugin-transform-optional-catch-binding": "^7.22.5", - "@babel/plugin-transform-optional-chaining": "^7.22.10", + "@babel/plugin-transform-optional-catch-binding": "^7.22.11", + "@babel/plugin-transform-optional-chaining": "^7.22.12", "@babel/plugin-transform-parameters": "^7.22.5", "@babel/plugin-transform-private-methods": "^7.22.5", - "@babel/plugin-transform-private-property-in-object": "^7.22.5", + "@babel/plugin-transform-private-property-in-object": "^7.22.11", "@babel/plugin-transform-property-literals": "^7.22.5", "@babel/plugin-transform-regenerator": "^7.22.10", "@babel/plugin-transform-reserved-words": "^7.22.5", @@ -1308,7 +1308,7 @@ "@babel/plugin-transform-unicode-regex": "^7.22.5", "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", "@babel/preset-modules": "0.1.6-no-external-plugins", - "@babel/types": "^7.22.10", + "@babel/types": "^7.22.11", "babel-plugin-polyfill-corejs2": "^0.4.5", "babel-plugin-polyfill-corejs3": "^0.8.3", "babel-plugin-polyfill-regenerator": "^0.5.2", @@ -2469,7 +2469,7 @@ }, "@rush-temp/bs-neo-legacy": { "version": "file:projects/bs-neo-legacy.tgz", - "integrity": "sha512-IFtdF9EyVPbS9hJiou/cJHdh9wekKt0U5IVVPlFXu66nMlmCJGHmBnxd/JX4w7bzTIj9mydE+lgXcqXuyscc8Q==", + "integrity": "sha512-TIjMfWORh87i9clZaeBD1/rD71XB49NYtu4t96MMusAFwNvpcwW+fv8gh4zUCSZgIUR9J8BsQ/Y6SOcVCfkieg==", "requires": { "@cityofzion/dora-ts": "0.0.11", "@cityofzion/neon-js": "4.8.3", @@ -2568,7 +2568,7 @@ }, "@rush-temp/bs-neo3": { "version": "file:projects/bs-neo3.tgz", - "integrity": "sha512-v/i/vy1RDbomrqRTEF8E7qTclRNGcu7En7TU78Psq2x26cWd7OBdW13nixfFg81uLLo+GPBUsbAwYSIeyVCzdA==", + "integrity": "sha512-lY3oaL3cBwBjJONOX05CKG2DVKf3bVYO8YxGwN/Fc8oOaI4YYVK73uLwukULcXv+oDrsaN7V27U2kunK2vhVqw==", "requires": { "@cityofzion/dora-ts": "0.0.11", "@cityofzion/neo3-invoker": "1.6.0", @@ -2578,10 +2578,9 @@ "@cityofzion/neon-js": "5.3.0", "@cityofzion/neon-parser": "1.6.2", "@types/jest": "29.5.3", - "@types/querystringify": "~2.0.0", "dotenv": "16.3.1", "jest": "29.6.2", - "querystringify": "~2.2.0", + "query-string": "7.1.3", "ts-jest": "29.1.1", "ts-node": "10.9.1", "typescript": "4.9.5" @@ -2809,11 +2808,6 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, - "@types/querystringify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/querystringify/-/querystringify-2.0.0.tgz", - "integrity": "sha512-9WgEGTevECrXJC2LSWPqiPYWq8BRmeaOyZn47js/3V6UF0PWtcVfvvR43YjeO8BzBsthTz98jMczujOwTw+WYg==" - }, "@types/react": { "version": "17.0.21", "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.21.tgz", @@ -3697,6 +3691,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==" }, + "decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==" + }, "dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -3997,6 +3996,11 @@ "to-regex-range": "^5.0.1" } }, + "filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==" + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -6470,10 +6474,16 @@ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.2.tgz", "integrity": "sha512-6Yg0ekpKICSjPswYOuC5sku/TSWaRYlA0qsXqJgM/d/4pLPHPuTxK7Nbf7jFKzAeedUhR8C7K9Uv63FBsSo8xQ==" }, - "querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + "query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "requires": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } }, "queue": { "version": "6.0.2", @@ -7139,6 +7149,11 @@ "source-map": "^0.6.0" } }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -7184,6 +7199,11 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==" + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/packages/blockchain-service/package.json b/packages/blockchain-service/package.json index 93b2579..3d8840b 100644 --- a/packages/blockchain-service/package.json +++ b/packages/blockchain-service/package.json @@ -1,6 +1,6 @@ { "name": "@cityofzion/blockchain-service", - "version": "0.6.0", + "version": "0.7.0", "main": "dist/index.js", "types": "dist/index.d.ts", "repository": "https://github.com/CityOfZion/blockchain-services", diff --git a/packages/blockchain-service/src/BSAgreggator.ts b/packages/blockchain-service/src/BSAgreggator.ts index 770ba99..851be21 100644 --- a/packages/blockchain-service/src/BSAgreggator.ts +++ b/packages/blockchain-service/src/BSAgreggator.ts @@ -1,5 +1,5 @@ import { BlockchainAlreadyExist, InvalidBlockchainService } from './exceptions' -import { BlockchainService, Claimable } from "./interfaces"; +import { BlockchainService } from "./interfaces"; export class BSAgreggator { readonly blockchainservices: Record private bsList: BlockchainService[] @@ -32,17 +32,17 @@ export class BSAgreggator [bs.validateAddress(text), bs.validateEncryptedKey(text), bs.validateWif(text)].some(it => it === true)) + return this.bsList.some(bs => [bs.validateAddress(text), bs.validateEncrypted(text), bs.validateKey(text)].some(it => it === true)) } validateWifAllBlockchains(wif: string) { if (this.haveBlockchainServices()) throw new InvalidBlockchainService(JSON.stringify(this.blockchainservices)) - return this.bsList.some(bs => bs.validateWif(wif)) + return this.bsList.some(bs => bs.validateKey(wif)) } validateEncryptedKeyAllBlockchains(encryptedKey: string) { if (this.haveBlockchainServices()) throw new InvalidBlockchainService(JSON.stringify(this.blockchainservices)) - return this.bsList.some(bs => bs.validateEncryptedKey(encryptedKey)) + return this.bsList.some(bs => bs.validateEncrypted(encryptedKey)) } getBlockchainByAddress(address: string): BlockchainService | null { @@ -52,21 +52,11 @@ export class BSAgreggator | null { if (this.haveBlockchainServices()) throw new InvalidBlockchainService(JSON.stringify(this.blockchainservices)) - return this.bsList.find(bs => bs.validateWif(wif)) ?? null + return this.bsList.find(bs => bs.validateKey(wif)) ?? null } getBlockchainByEncryptedKey(encryptedKey: string): BlockchainService | null { if (this.haveBlockchainServices()) throw new InvalidBlockchainService(JSON.stringify(this.blockchainservices)) - return this.bsList.find(bs => bs.validateEncryptedKey(encryptedKey)) ?? null - } - - getBlockchainsClaimable() { - const methodName = { claim: 'claim', getUnclaimed: 'getUnclaimed', tokenClaim: 'tokenClaim' } - const claimableBlockchains = this.bsList.filter( - blockchain => methodName.claim in blockchain && - methodName.getUnclaimed in blockchain.dataService && - methodName.tokenClaim in blockchain - ) as Array & Claimable> - return claimableBlockchains + return this.bsList.find(bs => bs.validateEncrypted(encryptedKey)) ?? null } } \ No newline at end of file diff --git a/packages/blockchain-service/src/functions.ts b/packages/blockchain-service/src/functions.ts index 02b01de..85fef8c 100644 --- a/packages/blockchain-service/src/functions.ts +++ b/packages/blockchain-service/src/functions.ts @@ -1,13 +1,17 @@ -import { BlockchainService, CalculableFee, Claimable, NeoNameService } from './interfaces' +import { BlockchainService, BSCalculableFee, BSClaimable, BSWithNameService, BSWithNft } from './interfaces' -export function hasNNS(service: BlockchainService): service is NeoNameService & BlockchainService { +export function hasNameService(service: BlockchainService): service is BSWithNameService & BlockchainService { return 'getNNSRecord' in service && 'getOwnerOfNNS' in service && 'validateNNSFormat' in service } -export function isClaimable(service: BlockchainService): service is Claimable & BlockchainService { - return 'claim' in service && 'tokenClaim' in service && 'getUnclaimed' in service.dataService +export function isClaimable(service: BlockchainService): service is BSClaimable & BlockchainService { + return 'claim' in service && 'claimToken' in service && 'getUnclaimed' in service.blockchainDataService } -export function isCalculableFee(service: BlockchainService): service is CalculableFee & BlockchainService { +export function isCalculableFee(service: BlockchainService): service is BSCalculableFee & BlockchainService { return 'calculateTransferFee' in service } + +export function hasNft(service: BlockchainService): service is BSWithNft & BlockchainService { + return 'nftDataService' in service +} diff --git a/packages/blockchain-service/src/interfaces.ts b/packages/blockchain-service/src/interfaces.ts index bf87993..fc98d64 100644 --- a/packages/blockchain-service/src/interfaces.ts +++ b/packages/blockchain-service/src/interfaces.ts @@ -1,7 +1,8 @@ export type PartialBy = Omit & Partial> export type Account = { - wif: string + key: string + type: 'wif' | 'privateKey' address: string } export interface Token { @@ -23,7 +24,8 @@ export type IntentTransferParam = { } export type TransferParam = { senderAccount: Account - intents: IntentTransferParam[] + intent: IntentTransferParam + tipIntent?: IntentTransferParam priorityFee?: number } @@ -32,44 +34,42 @@ export type TokenPricesResponse = { symbol: string } export type Currency = 'USD' | 'BRL' | 'EUR' -export interface Exchange { - readonly network: Network +export interface ExchangeDataService { getTokenPrices(currency: Currency): Promise } export interface BlockchainService { - readonly dataService: BlockchainDataService + readonly blockchainDataService: BlockchainDataService readonly blockchainName: BSCustomName readonly feeToken: Token - readonly exchange: Exchange + readonly exchangeDataService: ExchangeDataService readonly tokens: Token[] network: Network setNetwork: (network: PartialBy) => void generateMnemonic(): string[] generateAccount(mnemonic: string[], index: number): Account - generateAccountFromWif(wif: string): Account - decryptKey(encryptedKey: string, password: string): Promise + generateAccountFromKey(key: string): Account + decrypt(keyOrJson: string, password: string): Promise validateAddress(address: string): boolean - validateEncryptedKey(encryptedKey: string): boolean - validateWif(wif: string): boolean + validateEncrypted(keyOrJson: string): boolean + validateKey(key: string): boolean transfer(param: TransferParam): Promise } -export type CalculateTransferFeeResponse = { - total: number - systemFee: number - networkFee: number +export interface BSCalculableFee { + calculateTransferFee(param: TransferParam, details?: boolean): Promise } -export interface CalculableFee { - calculateTransferFee(param: TransferParam, details?: boolean): Promise -} -export interface Claimable { - dataService: BlockchainDataService & BDSClaimable - tokenClaim: Token +export interface BSClaimable { + blockchainDataService: BlockchainDataService & BDSClaimable + claimToken: Token claim(account: Account): Promise } -export interface NeoNameService { - getOwnerOfNNS(domainName: string): Promise - validateNNSFormat(domainName: string): boolean +export interface BSWithNameService { + resolveNameServiceDomain(domainName: string): Promise + validateNameServiceDomainFormat(domainName: string): boolean +} + +export interface BSWithNft { + nftDataService: NftDataService } export type TransactionNotifications = { @@ -80,25 +80,26 @@ export type TransactionNotifications = { }[] } export type TransactionTransferAsset = { - amount: string + amount: number to: string from: string - type: 'asset' + type: 'token' + contractHash: string + token?: Token } export type TransactionTransferNft = { tokenId: string to: string from: string type: 'nft' + contractHash: string } export type TransactionResponse = { hash: string block: number - time: string + time: number transfers: (TransactionTransferAsset | TransactionTransferNft)[] - sysfee?: string - netfee?: string - totfee?: string + fee?: number notifications: TransactionNotifications[] } export type ContractParameter = { @@ -120,15 +121,11 @@ export type ContractResponse = { } export type BalanceResponse = { amount: number - hash: string - symbol: string - name: string - decimals: number + token: Token } export interface BlockchainDataService { - readonly network: Network getTransaction(txid: string): Promise - getHistoryTransactions(address: string, page: number): Promise + getTransactionsByAddress(address: string, page: number): Promise getContract(contractHash: string): Promise getTokenInfo(tokenHash: string): Promise getBalance(address: string): Promise @@ -136,7 +133,7 @@ export interface BlockchainDataService { export interface BDSClaimable { getUnclaimed(address: string): Promise } -export interface NFTResponse { +export interface NftResponse { id: string contractHash: string collectionName?: string @@ -146,11 +143,23 @@ export interface NFTResponse { name?: string isSVG?: boolean } -export interface NFTSResponse { - totalPages: number - items: NFTResponse[] +export interface NftsResponse { + items: NftResponse[] + nextCursor?: string + total?: number +} + +export type GetNftsByAddressParams = { + address: string + page?: number + cursor?: string + size?: number +} +export type GetNftParam = { + tokenId: string + contractHash: string } export interface NftDataService { - getNFTS(address: string, page: number): Promise - getNFT(tokenID: string, hash: string): Promise + getNftsByAddress(params: GetNftsByAddressParams): Promise + getNft(params: GetNftParam): Promise } diff --git a/packages/bs-asteroid-sdk/package.json b/packages/bs-asteroid-sdk/package.json index 308b2de..fab4881 100644 --- a/packages/bs-asteroid-sdk/package.json +++ b/packages/bs-asteroid-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cityofzion/bs-asteroid-sdk", - "version": "0.6.0", + "version": "0.7.0", "main": "dist/index.js", "types": "dist/index.d.ts", "repository": "https://github.com/CityOfZion/blockchain-services", diff --git a/packages/bs-ethereum/jest.config.ts b/packages/bs-ethereum/jest.config.ts new file mode 100644 index 0000000..d944475 --- /dev/null +++ b/packages/bs-ethereum/jest.config.ts @@ -0,0 +1,13 @@ +import { JestConfigWithTsJest } from 'ts-jest' +const config: JestConfigWithTsJest = { + preset: 'ts-jest', + testEnvironment: 'node', + clearMocks: true, + verbose: true, + bail: true, + testMatch: ['/**/*.spec.ts'], + setupFiles: ['/jest.setup.ts'], + detectOpenHandles: true, +} + +export default config diff --git a/packages/bs-ethereum/jest.setup.ts b/packages/bs-ethereum/jest.setup.ts new file mode 100644 index 0000000..9a1976a --- /dev/null +++ b/packages/bs-ethereum/jest.setup.ts @@ -0,0 +1 @@ +import 'dotenv/config' diff --git a/packages/bs-ethereum/package.json b/packages/bs-ethereum/package.json new file mode 100644 index 0000000..b8bda9d --- /dev/null +++ b/packages/bs-ethereum/package.json @@ -0,0 +1,31 @@ +{ + "name": "@cityofzion/bs-ethereum", + "version": "0.7.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": "https://github.com/CityOfZion/blockchain-services", + "author": "Coz", + "license": "MIT", + "scripts": { + "build": "tsc --project tsconfig.build.json", + "test": "jest --config jest.config.ts" + }, + "dependencies": { + "@cityofzion/blockchain-service": "0.7.0", + "ethers": "6.7.1", + "@urql/core": "~4.1.1", + "graphql": "~16.8.0", + "node-fetch": "2.6.4", + "dayjs": "~1.11.9", + "query-string": "7.1.3" + }, + "devDependencies": { + "@types/node-fetch": "2.6.4", + "ts-node": "10.9.1", + "typescript": "4.9.5", + "jest": "29.6.2", + "ts-jest": "29.1.1", + "@types/jest": "29.5.3", + "dotenv": "16.3.1" + } +} \ No newline at end of file diff --git a/packages/bs-ethereum/src/BSEthereum.ts b/packages/bs-ethereum/src/BSEthereum.ts new file mode 100644 index 0000000..a0bb6bc --- /dev/null +++ b/packages/bs-ethereum/src/BSEthereum.ts @@ -0,0 +1,178 @@ +import { + Account, + BSCalculableFee, + BSWithNameService, + BSWithNft, + BlockchainDataService, + BlockchainService, + ExchangeDataService, + Network, + NftDataService, + PartialBy, + Token, + TransferParam, +} from '@cityofzion/blockchain-service' +import { ethers } from 'ethers' +import { DEFAULT_URL_BY_NETWORK_TYPE, DERIVATION_PATH, NATIVE_ASSETS, TOKENS } from './constants' +import { BitqueryEDSEthereum } from './BitqueryEDSEthereum' +import { GhostMarketNDSEthereum } from './GhostMarketNDSEthereum' +import { RpcBDSEthereum } from './RpcBDSEthereum' +import { BitqueryBDSEthereum } from './BitqueryBDSEthereum' + +export class BSEthereum + implements BlockchainService, BSWithNft, BSWithNameService, BSCalculableFee +{ + blockchainDataService!: BlockchainDataService + blockchainName: BSCustomName + feeToken: Token + exchangeDataService!: ExchangeDataService + tokens: Token[] + network!: Network + nftDataService!: NftDataService + + constructor(blockchainName: BSCustomName, network: PartialBy) { + this.blockchainName = blockchainName + this.tokens = TOKENS[network.type] + + this.feeToken = this.tokens.find(token => token.symbol === 'ETH')! + this.setNetwork(network) + } + + setNetwork(param: PartialBy) { + const network = { + type: param.type, + url: param.url ?? DEFAULT_URL_BY_NETWORK_TYPE[param.type], + } + this.network = network + + if (network.type === 'custom') { + this.blockchainDataService = new RpcBDSEthereum(network) + } else { + this.blockchainDataService = new BitqueryBDSEthereum(network.type) + } + + this.exchangeDataService = new BitqueryEDSEthereum(network.type) + this.nftDataService = new GhostMarketNDSEthereum(network.type) + } + + validateAddress(address: string): boolean { + return ethers.isAddress(address) + } + + validateEncrypted(json: string): boolean { + return ethers.isKeystoreJson(json) + } + + validateKey(key: string): boolean { + try { + if (!key.startsWith('0x')) { + key = '0x' + key + } + if (ethers.dataLength(key) !== 32) return false + return true + } catch (error) { + return false + } + } + + validateNameServiceDomainFormat(domainName: string): boolean { + return ethers.isValidName(domainName) + } + + generateMnemonic(): string[] { + const wallet = ethers.Wallet.createRandom() + if (!wallet.mnemonic) throw new Error('No mnemonic found') + + return wallet.mnemonic.phrase.split(' ') + } + + generateAccount(mnemonic: string[], index: number): Account { + const wallet = ethers.HDNodeWallet.fromPhrase( + mnemonic.join(' '), + undefined, + DERIVATION_PATH.replace('?', index.toString()), + undefined + ) + + return { + address: wallet.address, + key: wallet.privateKey, + type: 'privateKey', + } + } + + generateAccountFromKey(key: string): Account { + const wallet = new ethers.Wallet(key) + return { + address: wallet.address, + key, + type: 'privateKey', + } + } + + async decrypt(json: string, password: string): Promise { + const wallet = await ethers.Wallet.fromEncryptedJson(json, password) + return { + address: wallet.address, + key: wallet.privateKey, + type: 'privateKey', + } + } + + async transfer({ senderAccount, intent }: TransferParam): Promise { + const provider = new ethers.JsonRpcProvider(this.network.url) + const wallet = new ethers.Wallet(senderAccount.key, provider) + + let transaction: ethers.TransactionResponse + + const isNative = NATIVE_ASSETS.some(asset => asset.hash === intent.tokenHash) + if (!isNative) { + ethers.parseUnits + const abi = new ethers.Interface(['function transfer(address _to, uint256 _value) public returns (bool success)']) + const contract = new ethers.Contract(intent.tokenHash, abi, wallet) + const transferFunc = contract.getFunction('transfer') + const amount = ethers.FixedNumber.fromString(intent.amount.toFixed(intent.tokenDecimals ?? 18)).value + transaction = await transferFunc.send(intent.receiverAddress, amount) + } else { + transaction = await wallet.sendTransaction({ + to: intent.receiverAddress, + value: ethers.FixedNumber.fromString(intent.amount.toFixed(intent.tokenDecimals ?? 18)).value, + }) + } + + const transactionMined = await transaction.wait() + if (!transactionMined) throw new Error('Transaction not mined') + + return transactionMined.hash + } + + async calculateTransferFee({ senderAccount, intent }: TransferParam, details?: boolean | undefined): Promise { + const provider = new ethers.JsonRpcProvider(this.network.url) + const wallet = new ethers.Wallet(senderAccount.key, provider) + + let estimated: bigint + + const isNative = NATIVE_ASSETS.some(asset => asset.hash === intent.tokenHash) + if (!isNative) { + const abi = new ethers.Interface(['function transfer(address _to, uint256 _value) public returns (bool success)']) + const contract = new ethers.Contract(intent.tokenHash, abi, wallet) + const amount = ethers.parseUnits(intent.amount.toString(), intent.tokenDecimals ?? 0) + const transferFunc = contract.getFunction('transfer') + estimated = await transferFunc.estimateGas(intent.receiverAddress, amount) + } else { + estimated = await wallet.estimateGas({ + to: intent.receiverAddress, + value: ethers.parseEther(intent.amount.toFixed(intent.tokenDecimals ?? 0)), + }) + } + + return ethers.formatEther(estimated) + } + + async resolveNameServiceDomain(domainName: string): Promise { + const provider = new ethers.JsonRpcProvider(this.network.url) + const address = await provider.resolveName(domainName) + if (!address) throw new Error('No address found for domain name') + return address + } +} diff --git a/packages/bs-ethereum/src/BitqueryBDSEthereum.ts b/packages/bs-ethereum/src/BitqueryBDSEthereum.ts new file mode 100644 index 0000000..b1bc8c0 --- /dev/null +++ b/packages/bs-ethereum/src/BitqueryBDSEthereum.ts @@ -0,0 +1,209 @@ +import { + BalanceResponse, + BlockchainDataService, + ContractResponse, + NetworkType, + Token, + TransactionHistoryResponse, + TransactionResponse, + TransactionTransferAsset, + TransactionTransferNft, +} from '@cityofzion/blockchain-service' +import { Client, cacheExchange, fetchExchange, gql } from '@urql/core' +import fetch from 'node-fetch' +import { BITQUERY_API_KEY, BITQUERY_NETWORK_BY_NETWORK_TYPE, BITQUERY_URL, TOKENS } from './constants' +import { + BitqueryTransaction, + bitqueryGetBalanceQuery, + bitqueryGetTokenInfoQuery, + bitqueryGetTransactionQuery, + bitqueryGetTransactionsByAddressQuery, +} from './graphql' + +export class BitqueryBDSEthereum implements BlockchainDataService { + private readonly client: Client + private readonly networkType: Exclude + + constructor(networkType: NetworkType) { + if (networkType === 'custom') throw new Error('Custom network not supported') + this.networkType = networkType + + this.client = new Client({ + url: BITQUERY_URL, + exchanges: [cacheExchange, fetchExchange], + fetch, + fetchOptions: { + headers: { + 'X-API-KEY': BITQUERY_API_KEY, + }, + }, + }) + } + + async getTransaction(hash: string): Promise { + const result = await this.client + .query(bitqueryGetTransactionQuery, { + hash, + network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType], + }) + .toPromise() + if (result.error) throw new Error(result.error.message) + if (!result.data || !result.data.ethereum.transfers.length) throw new Error('Transaction not found') + + const transfers = result.data.ethereum.transfers.map(this.parseTransactionTransfer) + + const { + block: { + height, + timestamp: { unixtime }, + }, + transaction: { gasValue, hash: transactionHash }, + } = result.data.ethereum.transfers[0] + + return { + block: height, + time: unixtime, + hash: transactionHash, + fee: gasValue, + transfers, + notifications: [], + } + } + + async getTransactionsByAddress(address: string, page: number): Promise { + const limit = 10 + const offset = limit * (page - 1) + + const result = await this.client + .query(bitqueryGetTransactionsByAddressQuery, { + address, + limit, + offset, + network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType], + }) + .toPromise() + + if (result.error) throw new Error(result.error.message) + if (!result.data) throw new Error('Address does not have transactions') + + const totalCount = result.data.ethereum.sentCount.count + result.data.ethereum.receiverCount.count + const mixedTransfers = [...(result?.data?.ethereum?.sent ?? []), ...(result?.data?.ethereum?.received ?? [])] + + const transactions = new Map() + + mixedTransfers.forEach(transfer => { + const transactionTransfer = this.parseTransactionTransfer(transfer) + + const existingTransaction = transactions.get(transfer.transaction.hash) + if (existingTransaction) { + existingTransaction.transfers.push(transactionTransfer) + return + } + + transactions.set(transfer.transaction.hash, { + block: transfer.block.height, + hash: transfer.transaction.hash, + time: transfer.block.timestamp.unixtime, + fee: transfer.transaction.gasValue, + transfers: [transactionTransfer], + notifications: [], + }) + }) + + return { + totalCount, + transactions: Array.from(transactions.values()), + } + } + + async getContract(): Promise { + throw new Error("Bitquery doesn't support contract info") + } + + async getTokenInfo(hash: string): Promise { + const localToken = TOKENS[this.networkType].find(token => token.hash === hash) + if (localToken) return localToken + + const result = await this.client + .query(bitqueryGetTokenInfoQuery, { + hash, + network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType], + }) + .toPromise() + + if (result.error) throw new Error(result.error.message) + if (!result.data) throw new Error('Token not found') + + const { + address: { address }, + currency: { decimals, name, symbol, tokenType }, + } = result.data.ethereum.smartContractCalls[0].smartContract + + if (tokenType !== 'ERC20') throw new Error('Token is not ERC20') + + return { + hash: address, + name, + symbol, + decimals, + } + } + + async getBalance(address: string): Promise { + const result = await this.client + .query(bitqueryGetBalanceQuery, { + address, + network: BITQUERY_NETWORK_BY_NETWORK_TYPE[this.networkType], + }) + .toPromise() + + if (result.error) throw new Error(result.error.message) + if (!result.data) throw new Error('Balance not found') + + const balances = result.data.ethereum.address[0].balances.map( + ({ value, currency: { address, decimals, name, symbol } }): BalanceResponse => ({ + amount: value, + token: { + hash: address, + symbol, + name, + decimals, + }, + }) + ) + + return balances + } + + private parseTransactionTransfer({ + amount, + currency: { tokenType, address, decimals, name, symbol }, + entityId, + sender, + receiver, + }: BitqueryTransaction): TransactionTransferAsset | TransactionTransferNft { + if (tokenType === 'ERC721') { + return { + from: sender.address, + to: receiver.address, + tokenId: entityId, + contractHash: address, + type: 'nft', + } + } + + return { + from: sender.address, + to: receiver.address, + contractHash: address, + amount: amount, + token: { + decimals: decimals, + hash: address, + name: name, + symbol: symbol, + }, + type: 'token', + } + } +} diff --git a/packages/bs-ethereum/src/BitqueryEDSEthereum.ts b/packages/bs-ethereum/src/BitqueryEDSEthereum.ts new file mode 100644 index 0000000..5036d10 --- /dev/null +++ b/packages/bs-ethereum/src/BitqueryEDSEthereum.ts @@ -0,0 +1,66 @@ +import { Currency, ExchangeDataService, NetworkType, TokenPricesResponse } from '@cityofzion/blockchain-service' +import { Client, cacheExchange, fetchExchange, gql } from '@urql/core' +import fetch from 'node-fetch' +import { BITQUERY_API_KEY, BITQUERY_URL } from './constants' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { bitqueryGetPricesQuery } from './graphql' + +dayjs.extend(utc) +export class BitqueryEDSEthereum implements ExchangeDataService { + private readonly client: Client + private readonly networkType: NetworkType + + constructor(networkType: NetworkType) { + this.networkType = networkType + + this.client = new Client({ + url: BITQUERY_URL, + exchanges: [cacheExchange, fetchExchange], + fetch, + fetchOptions: { + headers: { + 'X-API-KEY': BITQUERY_API_KEY, + }, + }, + }) + } + + async getTokenPrices(currency: Currency): Promise { + if (this.networkType !== 'mainnet') throw new Error('Exchange is only available on mainnet') + + const twoDaysAgo = dayjs.utc().subtract(2, 'day').startOf('date').toISOString() + + const result = await this.client + .query(bitqueryGetPricesQuery, { after: twoDaysAgo, network: 'ethereum' }) + .toPromise() + if (result.error) { + throw new Error(result.error.message) + } + if (!result.data) { + throw new Error('There is no price data') + } + + let currencyRatio: number = 1 + if (currency !== 'USD') { + currencyRatio = await this.getCurrencyRatio(currency) + } + + const prices = result.data.ethereum.dexTrades.map( + (trade): TokenPricesResponse => ({ + symbol: trade.baseCurrency.symbol, + amount: trade.quotePrice * currencyRatio, + }) + ) + + return prices + } + + private async getCurrencyRatio(currency: Currency): Promise { + const request = await fetch(`https://api.flamingo.finance/fiat/exchange-rate?pair=USD_${currency}`, { + method: 'GET', + }) + const data = await request.json() + return data + } +} diff --git a/packages/bs-ethereum/src/GhostMarketNDSEthereum.ts b/packages/bs-ethereum/src/GhostMarketNDSEthereum.ts new file mode 100644 index 0000000..1452732 --- /dev/null +++ b/packages/bs-ethereum/src/GhostMarketNDSEthereum.ts @@ -0,0 +1,122 @@ +import { + BlockchainService, + NftResponse, + NftsResponse, + NetworkType, + NftDataService, + GetNftParam, + GetNftsByAddressParams, +} from '@cityofzion/blockchain-service' +import qs from 'query-string' + +import { GHOSTMARKET_CHAIN_BY_NETWORK_TYPE, GHOSTMARKET_URL_BY_NETWORK_TYPE } from './constants' +import fetch from 'node-fetch' + +type GhostMarketNFT = { + tokenId: string + contract: { + chain?: string + hash: string + symbol: string + } + creator: { + address?: string + offchainName?: string + } + apiUrl?: string + ownerships: { + owner: { + address?: string + } + }[] + collection: { + name?: string + logoUrl?: string + } + metadata: { + description: string + mediaType: string + mediaUri: string + mintDate: number + mintNumber: number + name: string + } +} + +type GhostMarketAssets = { + assets: GhostMarketNFT[] + next: string +} +export class GhostMarketNDSEthereum implements NftDataService { + private networkType: NetworkType + + constructor(networkType: NetworkType) { + this.networkType = networkType + } + + async getNftsByAddress({ address, size = 18, cursor, page }: GetNftsByAddressParams): Promise { + const url = this.getUrlWithParams({ + size, + owners: [address], + cursor: cursor, + }) + + const request = await fetch(url, { method: 'GET' }) + const data = (await request.json()) as GhostMarketAssets + const nfts = data.assets ?? [] + + return { nextCursor: data.next, items: nfts.map(this.parse.bind(this)) } + } + + async getNft({ contractHash, tokenId }: GetNftParam): Promise { + const url = this.getUrlWithParams({ + contract: contractHash, + tokenIds: [tokenId], + }) + + const request = await fetch(url, { method: 'GET' }) + const data = (await request.json()) as GhostMarketAssets + + return this.parse(data.assets[0]) + } + + private treatGhostMarketImage(srcImage?: string) { + if (!srcImage) { + return + } + + if (srcImage.startsWith('ipfs://')) { + const [, imageId] = srcImage.split('://') + + return `https://ghostmarket.mypinata.cloud/ipfs/${imageId}` + } + + return srcImage + } + + private getUrlWithParams(params: any) { + const parameters = qs.stringify( + { + chain: GHOSTMARKET_CHAIN_BY_NETWORK_TYPE[this.networkType], + ...params, + }, + { arrayFormat: 'bracket' } + ) + return `${GHOSTMARKET_URL_BY_NETWORK_TYPE[this.networkType]}/assets?${parameters}` + } + + private parse(data: GhostMarketNFT) { + const nftResponse: NftResponse = { + collectionImage: this.treatGhostMarketImage(data.collection?.logoUrl), + id: data.tokenId, + contractHash: data.contract.hash, + symbol: data.contract.symbol, + collectionName: data.collection?.name, + image: this.treatGhostMarketImage(data.metadata.mediaUri), + isSVG: String(data.metadata.mediaType).includes('svg+xml'), + name: data.metadata.name, + } + + return nftResponse + } +} diff --git a/packages/bs-ethereum/src/RpcBDSEthereum.ts b/packages/bs-ethereum/src/RpcBDSEthereum.ts new file mode 100644 index 0000000..db2ecd3 --- /dev/null +++ b/packages/bs-ethereum/src/RpcBDSEthereum.ts @@ -0,0 +1,81 @@ +import { + BalanceResponse, + BlockchainDataService, + ContractResponse, + Network, + NetworkType, + Token, + TransactionHistoryResponse, + TransactionResponse, +} from '@cityofzion/blockchain-service' +import { ethers } from 'ethers' +import { TOKENS } from './constants' + +export class RpcBDSEthereum implements BlockchainDataService { + private readonly network: Network + + constructor(network: Network) { + this.network = network + } + + async getTransaction(hash: string): Promise { + const provider = new ethers.JsonRpcProvider(this.network.url) + + const transaction = await provider.getTransaction(hash) + if (!transaction || !transaction.blockHash || !transaction.to) throw new Error('Transaction not found') + + const block = await provider.getBlock(transaction.blockHash) + if (!block) throw new Error('Block not found') + + const tokens = TOKENS[this.network.type] + const token = tokens.find(token => token.symbol === 'ETH')! + + return { + block: block.number, + time: block.timestamp, + hash: transaction.hash, + transfers: [ + { + type: 'token', + amount: Number(ethers.formatEther(transaction.value)), + contractHash: '-', + from: transaction.from, + to: transaction.to, + token, + }, + ], + notifications: [], + } + } + + async getTransactionsByAddress(address: string, page: number): Promise { + throw new Error("RPC doesn't support get transactions history of address") + } + + async getContract(): Promise { + throw new Error("RPC doesn't support contract info") + } + + async getTokenInfo(hash: string): Promise { + const tokens = TOKENS[this.network.type] + const token = tokens.find(token => token.hash === hash) + if (!token) throw new Error('Token not found') + + return token + } + + async getBalance(address: string): Promise { + const provider = new ethers.JsonRpcProvider(this.network.url) + const balance = await provider.getBalance(address) + + const tokens = TOKENS[this.network.type] + const token = tokens.find(token => token.symbol === 'ETH')! + + return [ + { + amount: Number(ethers.formatEther(balance)), + token, + }, + ] + } +} diff --git a/packages/bs-ethereum/src/__tests__/BDSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/BDSEthereum.spec.ts new file mode 100644 index 0000000..456f7cb --- /dev/null +++ b/packages/bs-ethereum/src/__tests__/BDSEthereum.spec.ts @@ -0,0 +1,121 @@ +import { BDSClaimable, BlockchainDataService } from '@cityofzion/blockchain-service' +import { BitqueryBDSEthereum } from '../BitqueryBDSEthereum' +import { RpcBDSEthereum } from '../RpcBDSEthereum' +import { DEFAULT_URL_BY_NETWORK_TYPE } from '../constants' + +let bitqueryBDSEthereum = new BitqueryBDSEthereum('testnet') +let rpcBDSEthereum = new RpcBDSEthereum({ type: 'testnet', url: DEFAULT_URL_BY_NETWORK_TYPE.testnet }) + +describe.only('BDSEthereum', () => { + it.each([rpcBDSEthereum, bitqueryBDSEthereum])( + 'Should be able to get transaction - %s', + async (BDSEthereum: BlockchainDataService) => { + const hash = '0xf375bdb7cd119b65b9808655c7786ca47d5761c98aeaa7d63cbad63d6fd99f24' + const transaction = await BDSEthereum.getTransaction(hash) + + expect(transaction).toEqual( + expect.objectContaining({ + block: expect.any(Number), + hash, + notifications: [], + time: expect.any(Number), + }) + ) + transaction.transfers.forEach(transfer => { + expect(transfer).toEqual( + expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + contractHash: expect.any(String), + amount: expect.any(Number), + type: expect.any(String), + }) + ) + }) + } + ) + + it.only.each([bitqueryBDSEthereum])( + 'Should be able to get transactions of address - %s', + async (BDSEthereum: BlockchainDataService) => { + const address = '0xFACf5446B71dB33E920aB1769d9427146183aEcd' + const response = await BDSEthereum.getTransactionsByAddress(address, 1) + response.transactions.forEach(transaction => { + expect(transaction).toEqual( + expect.objectContaining({ + block: expect.any(Number), + hash: expect.any(String), + notifications: [], + time: expect.any(Number), + fee: expect.any(Number), + }) + ) + + transaction.transfers.forEach(transfer => { + expect(transfer).toEqual( + expect.objectContaining({ + from: expect.any(String), + to: expect.any(String), + contractHash: expect.any(String), + amount: expect.any(Number), + type: expect.any(String), + }) + ) + }) + }) + }, + 10000 + ) + + it.each([bitqueryBDSEthereum, rpcBDSEthereum])( + 'Should be able to get eth info - %s', + async (BDSEthereum: BlockchainDataService) => { + const hash = '-' + const token = await BDSEthereum.getTokenInfo(hash) + + expect(token).toEqual({ + symbol: 'ETH', + name: 'Ethereum', + hash: '-', + decimals: 16, + }) + } + ) + + it.each([bitqueryBDSEthereum])( + 'Should be able to get token info - %s', + async (BDSEthereum: BlockchainDataService) => { + const hash = '0x9813037ee2218799597d83d4a5b6f3b6778218d9' + const token = await BDSEthereum.getTokenInfo(hash) + + expect(token).toEqual({ + symbol: 'BONE', + name: 'BONE SHIBASWAP', + hash: '0x9813037ee2218799597d83d4a5b6f3b6778218d9', + decimals: 18, + }) + } + ) + + it.only.each([bitqueryBDSEthereum])( + 'Should be able to get balance - %s', + async (BDSEthereum: BlockchainDataService) => { + const address = '0xFACf5446B71dB33E920aB1769d9427146183aEcd' + const balance = await BDSEthereum.getBalance(address) + console.log(JSON.stringify(balance, null, 2)) + balance.forEach(balance => { + expect(balance).toEqual( + expect.objectContaining({ + amount: expect.any(Number), + token: { + hash: expect.any(String), + name: expect.any(String), + symbol: expect.any(String), + decimals: expect.any(Number), + }, + }) + ) + }) + } + ) +}) diff --git a/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts new file mode 100644 index 0000000..e5fbf8c --- /dev/null +++ b/packages/bs-ethereum/src/__tests__/BSEthereum.spec.ts @@ -0,0 +1,131 @@ +import { ethers } from 'ethers' +import { BSEthereum } from '../BSEthereum' +import { Account } from '@cityofzion/blockchain-service' + +let bsEthereum: BSEthereum +let wallet: ethers.HDNodeWallet +let account: Account + +describe('BSEthereum', () => { + beforeAll(() => { + bsEthereum = new BSEthereum('neo3', { type: 'testnet' }) + wallet = ethers.Wallet.createRandom() + account = { + key: wallet.privateKey, + type: 'privateKey', + address: wallet.address, + } + }) + + it('Should be able to validate an address', () => { + const validAddress = '0xD81a8F3c3f8b006Ef1ae4a2Fd28699AD7E3e21C5' + const invalidAddress = 'invalid address' + + expect(bsEthereum.validateAddress(validAddress)).toBeTruthy() + expect(bsEthereum.validateAddress(invalidAddress)).toBeFalsy() + }) + + it('Should be able to validate an encrypted key', async () => { + const validEncryptedJson = await wallet.encrypt('password') + const invalidEncryptedJson = '{ invalid: json }' + + expect(bsEthereum.validateEncrypted(validEncryptedJson)).toBeTruthy() + expect(bsEthereum.validateEncrypted(invalidEncryptedJson)).toBeFalsy() + }) + + it('Should be able to validate a private key', () => { + const validKey = wallet.privateKey + const invalidKey = 'invalid key' + + expect(bsEthereum.validateKey(validKey)).toBeTruthy() + expect(bsEthereum.validateKey(invalidKey)).toBeFalsy() + }) + + it('Should be able to validate an domain', () => { + const validDomain = 'alice.eth' + const invalidDomain = 'invalid domain' + + expect(bsEthereum.validateNameServiceDomainFormat(validDomain)).toBeTruthy() + expect(bsEthereum.validateNameServiceDomainFormat(invalidDomain)).toBeFalsy() + }) + + it('Should be able to generate a mnemonic', () => { + expect(() => { + const mnemonic = bsEthereum.generateMnemonic() + expect(mnemonic).toHaveLength(12) + }).not.toThrowError() + }) + + it('Should be able to generate a account from mnemonic', () => { + const mnemonic = bsEthereum.generateMnemonic() + const account = bsEthereum.generateAccount(mnemonic, 0) + + expect(bsEthereum.validateAddress(account.address)).toBeTruthy() + expect(bsEthereum.validateKey(account.key)).toBeTruthy() + }) + + it('Should be able to generate a account from wif', () => { + const accountFromWif = bsEthereum.generateAccountFromKey(wallet.privateKey) + expect(accountFromWif).toEqual(account) + }) + + it('Should be able to decrypt a encrypted key', async () => { + const password = 'TestPassword' + const validEncryptedJson = await wallet.encrypt(password) + const decryptedAccount = await bsEthereum.decrypt(validEncryptedJson, password) + expect(decryptedAccount).toEqual(account) + }) + + it.skip('Should be able to calculate transfer fee', async () => { + const account = bsEthereum.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + + const fee = await bsEthereum.calculateTransferFee({ + senderAccount: account, + intent: { + amount: 0.00000001, + receiverAddress: '0xFACf5446B71dB33E920aB1769d9427146183aEcd', + tokenDecimals: 18, + tokenHash: '-', + }, + }) + + expect(fee).toEqual(expect.any(String)) + }) + + it.skip('Should be able to transfer a native token', async () => { + const account = bsEthereum.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + + const transactionHash = await bsEthereum.transfer({ + senderAccount: account, + intent: { + amount: 0.00000001, + receiverAddress: '0xFACf5446B71dB33E920aB1769d9427146183aEcd', + tokenDecimals: 18, + tokenHash: '-', + }, + }) + + expect(transactionHash).toEqual(expect.any(String)) + }, 50000) + + it.skip('Should be able to transfer a ERC20 token', async () => { + const account = bsEthereum.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + + const transactionHash = await bsEthereum.transfer({ + senderAccount: account, + intent: { + amount: 0.00000001, + receiverAddress: '0xFACf5446B71dB33E920aB1769d9427146183aEcd', + tokenDecimals: 18, + tokenHash: '0xba62bcfcaafc6622853cca2be6ac7d845bc0f2dc', + }, + }) + + expect(transactionHash).toEqual(expect.any(String)) + }, 50000) + + it('Should be able to resolve a name service domain', async () => { + const address = await bsEthereum.resolveNameServiceDomain('alice.eth') + expect(address).toEqual('0xa974890156A3649A23a6C0f2ebd77D6F7A7333d4') + }) +}) diff --git a/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts new file mode 100644 index 0000000..e45f537 --- /dev/null +++ b/packages/bs-ethereum/src/__tests__/BitqueryEDSEthereum.spec.ts @@ -0,0 +1,45 @@ +import { BitqueryEDSEthereum } from '../BitqueryEDSEthereum' + +let bitqueryEDSEthereum: BitqueryEDSEthereum + +describe('FlamingoEDSNeo3', () => { + beforeAll(() => { + bitqueryEDSEthereum = new BitqueryEDSEthereum('mainnet') + }) + it('Should return a list with prices of tokens using USD', async () => { + const tokenPriceList = await bitqueryEDSEthereum.getTokenPrices('USD') + + tokenPriceList.forEach(tokenPrice => { + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) + }) + + it('Should return a list with prices of tokens using BRL', async () => { + const tokenPriceListInUSD = await bitqueryEDSEthereum.getTokenPrices('USD') + const tokenPriceList = await bitqueryEDSEthereum.getTokenPrices('BRL') + + tokenPriceList.forEach((tokenPrice, index) => { + expect(tokenPrice.amount).toBeGreaterThan(tokenPriceListInUSD[index].amount) + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) + }) + + it('Should return a list with prices of tokens using EUR', async () => { + const tokenPriceListInUSD = await bitqueryEDSEthereum.getTokenPrices('USD') + const tokenPriceList = await bitqueryEDSEthereum.getTokenPrices('EUR') + + tokenPriceList.forEach((tokenPrice, index) => { + expect(tokenPrice.amount).toBeLessThan(tokenPriceListInUSD[index].amount) + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) + }) +}) diff --git a/packages/bs-ethereum/src/__tests__/GhostMarketNDSEthereum.spec.ts b/packages/bs-ethereum/src/__tests__/GhostMarketNDSEthereum.spec.ts new file mode 100644 index 0000000..f5c9ba8 --- /dev/null +++ b/packages/bs-ethereum/src/__tests__/GhostMarketNDSEthereum.spec.ts @@ -0,0 +1,45 @@ +import { GhostMarketNDSEthereum } from '../GhostMarketNDSEthereum' + +let ghostMarketNDSEthereum: GhostMarketNDSEthereum + +describe('GhostMarketNDSEthereum', () => { + beforeAll(() => { + ghostMarketNDSEthereum = new GhostMarketNDSEthereum('mainnet') + }) + + it('Get NFT', async () => { + const nft = await ghostMarketNDSEthereum.getNft({ + contractHash: '0xeb3a9a839dfeeaf71db1b4ed6a8ae0ccb171b227', + tokenId: '379', + }) + + expect(nft).toEqual( + expect.objectContaining({ + id: '379', + contractHash: '0xeb3a9a839dfeeaf71db1b4ed6a8ae0ccb171b227', + symbol: 'MOAR', + collectionImage: expect.any(String), + collectionName: '"MOAR" by Joan Cornella', + image: expect.any(String), + isSVG: expect.any(Boolean), + name: 'MOAR #379', + }) + ) + }) + + it('Get NFTS by address', async () => { + const nfts = await ghostMarketNDSEthereum.getNftsByAddress({ + address: '0xd773c81a4a855556ce2f2372b12272710b95b26c', + }) + expect(nfts.items.length).toBeGreaterThan(0) + nfts.items.forEach(nft => { + expect(nft).toEqual( + expect.objectContaining({ + symbol: expect.any(String), + id: expect.any(String), + contractHash: expect.any(String), + }) + ) + }) + }) +}) diff --git a/packages/bs-ethereum/src/assets/tokens/common.json b/packages/bs-ethereum/src/assets/tokens/common.json new file mode 100644 index 0000000..4dbbf3a --- /dev/null +++ b/packages/bs-ethereum/src/assets/tokens/common.json @@ -0,0 +1,8 @@ +[ + { + "symbol": "ETH", + "name": "Ethereum", + "hash": "-", + "decimals": 16 + } +] \ No newline at end of file diff --git a/packages/bs-ethereum/src/constants.ts b/packages/bs-ethereum/src/constants.ts new file mode 100644 index 0000000..40862a7 --- /dev/null +++ b/packages/bs-ethereum/src/constants.ts @@ -0,0 +1,37 @@ +import { NetworkType, Token } from '@cityofzion/blockchain-service' +import commom from './assets/tokens/common.json' + +export type BitqueryNetwork = 'ethereum' | 'goerli' + +export const TOKENS: Record = { + mainnet: [...commom], + testnet: commom, + custom: commom, +} + +export const NATIVE_ASSETS = commom + +export const DEFAULT_URL_BY_NETWORK_TYPE: Record = { + mainnet: 'https://ethereum-mainnet-rpc.allthatnode.com', + testnet: 'https://ethereum-goerli-rpc.allthatnode.com', + custom: 'http://127.0.0.1:8545', +} + +export const BITQUERY_API_KEY = 'BQYMp76Ny15C8ORbI2BOstFUhoMCahLI' +export const BITQUERY_URL = 'https://graphql.bitquery.io' +export const BITQUERY_NETWORK_BY_NETWORK_TYPE: Record, BitqueryNetwork> = { + mainnet: 'ethereum', + testnet: 'goerli', +} + +export const GHOSTMARKET_URL_BY_NETWORK_TYPE: Partial> = { + mainnet: 'https://api.ghostmarket.io/api/v2', + testnet: 'https://api-testnet.ghostmarket.io/api/v2', +} + +export const GHOSTMARKET_CHAIN_BY_NETWORK_TYPE: Partial> = { + mainnet: 'eth', + testnet: 'etht', +} + +export const DERIVATION_PATH = "m/44'/60'/0'/0/?" diff --git a/packages/bs-ethereum/src/graphql.ts b/packages/bs-ethereum/src/graphql.ts new file mode 100644 index 0000000..942e0d2 --- /dev/null +++ b/packages/bs-ethereum/src/graphql.ts @@ -0,0 +1,281 @@ +import { gql } from '@urql/core' + +type BitqueryNetwork = 'ethereum' | 'goerli' + +export type BitqueryTransaction = { + block: { + timestamp: { + unixtime: number + } + height: number + } + transaction: { + gasValue: number + hash: string + } + amount: number + currency: { + address: string + tokenType: string + decimals: number + symbol: string + name: string + } + sender: { + address: string + } + receiver: { + address: string + } + entityId: string +} + +type BitQueryGetTransactionsByAddressResponse = { + ethereum: { + sent: BitqueryTransaction[] + received: BitqueryTransaction[] + sentCount: { + count: number + } + receiverCount: { + count: number + } + } +} + +type BitQueryGetTransactionsByAddressVariables = { + address: string + limit: number + offset: number + network: BitqueryNetwork +} + +export const bitqueryGetTransactionsByAddressQuery = gql< + BitQueryGetTransactionsByAddressResponse, + BitQueryGetTransactionsByAddressVariables +>` + query getTransactions($address: String!, $limit: Int!, $offset: Int!, $network: EthereumNetwork!) { + ethereum(network: $network) { + sent: transfers(options: { limit: $limit, offset: $offset }, sender: { is: $address }) { + block { + timestamp { + unixtime + } + height + } + amount + currency { + address + tokenType + symbol + decimals + name + } + sender { + address + } + receiver { + address + } + transaction { + gasValue + hash + } + entityId + } + received: transfers(options: { limit: $limit, offset: $offset }, receiver: { is: $address }) { + block { + timestamp { + unixtime + } + height + } + amount + currency { + address + tokenType + } + sender { + address + } + receiver { + address + } + transaction { + gasValue + hash + } + entityId + } + sentCount: transfers(sender: { is: $address }) { + count + } + receiverCount: transfers(receiver: { is: $address }) { + count + } + } + } +` + +type BitQueryGetTransactionResponse = { + ethereum: { + transfers: BitqueryTransaction[] + } +} +type BitQueryGetTransactionVariables = { + hash: string + network: BitqueryNetwork +} +export const bitqueryGetTransactionQuery = gql` + query getTransaction($hash: String!, $network: EthereumNetwork!) { + ethereum(network: $network) { + transfers(txHash: { is: $hash }) { + block { + timestamp { + unixtime + } + height + } + amount + currency { + address + tokenType + } + sender { + address + } + receiver { + address + } + transaction { + gasValue + hash + } + entityId + } + } + } +` + +type BitQueryGetContractResponse = { + ethereum: { + smartContractCalls: { + smartContract: { + address: { + address: string + } + currency: { + symbol: string + name: string + decimals: number + tokenType: string + } + } + }[] + } +} +type BitQueryGetTokenInfoVariables = { + hash: string + network: BitqueryNetwork +} +export const bitqueryGetTokenInfoQuery = gql` + query getTokenInfo($hash: String!, $network: EthereumNetwork!) { + ethereum(network: $network) { + smartContractCalls(smartContractAddress: { is: $hash }) { + smartContract { + address { + address + } + currency { + symbol + name + decimals + tokenType + } + } + } + } + } +` + +type BitQueryGetBalanceResponse = { + ethereum: { + address: { + balances: { + currency: { + address: string + symbol: string + name: string + decimals: number + } + value: number + }[] + }[] + } +} +type BitQueryGetBalanceVariables = { + address: string + network: BitqueryNetwork +} +export const bitqueryGetBalanceQuery = gql` + query getBalance($address: String!, $network: EthereumNetwork!) { + ethereum(network: $network) { + address(address: { is: $address }) { + balances { + currency { + address + symbol + name + decimals + } + value + } + } + } + } +` +type BitQueryGetTokenPricesResponse = { + ethereum: { + dexTrades: { + baseCurrency: { + address: string + symbol: string + } + quoteCurrency: { + address: string + symbol: string + } + date: { + date: string + } + quotePrice: number + }[] + } +} +export type BitQueryGetTokenPricesVariables = { + after: string + network: BitqueryNetwork +} +export const bitqueryGetPricesQuery = gql` + query getPrice($after: ISO8601DateTime!, $network: EthereumNetwork!) { + ethereum(network: $network) { + dexTrades( + options: { limitBy: { each: "baseCurrency.address", limit: 1 }, desc: "date.date" } + time: { after: $after } + ) { + quoteCurrency(quoteCurrency: { is: "0xdac17f958d2ee523a2206206994597c13d831ec7" }) { + symbol + address + } + baseCurrency { + symbol + address + } + date { + date + } + quotePrice + } + } + } +` diff --git a/packages/bs-ethereum/src/index.ts b/packages/bs-ethereum/src/index.ts new file mode 100644 index 0000000..69ca9c2 --- /dev/null +++ b/packages/bs-ethereum/src/index.ts @@ -0,0 +1,6 @@ +export * from './BSEthereum' +export * from './GhostMarketNDSEthereum' +export * from './constants' +export * from './BitqueryBDSEthereum' +export * from './BitqueryEDSEthereum' +export * from './RpcBDSEthereum' diff --git a/packages/bs-ethereum/tsconfig.build.json b/packages/bs-ethereum/tsconfig.build.json new file mode 100644 index 0000000..4dc23fb --- /dev/null +++ b/packages/bs-ethereum/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__"] +} diff --git a/packages/bs-ethereum/tsconfig.json b/packages/bs-ethereum/tsconfig.json new file mode 100644 index 0000000..93e0976 --- /dev/null +++ b/packages/bs-ethereum/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "outDir": "./dist", + "strict": true + }, + "include": ["src"], + "exclude": ["node_modules"], + "typedocOptions": { + "entryPoints": ["./src/index.ts"], + "out": "docs", + "exclude": "**/node_modules/**" + } +} diff --git a/packages/bs-neo-legacy/package.json b/packages/bs-neo-legacy/package.json index 2e8d02c..18caac8 100644 --- a/packages/bs-neo-legacy/package.json +++ b/packages/bs-neo-legacy/package.json @@ -1,17 +1,17 @@ { "name": "@cityofzion/bs-neo-legacy", - "version": "0.6.0", + "version": "0.7.0", "main": "dist/index.js", "types": "dist/index.d.ts", "author": "Coz", "license": "MIT", "scripts": { - "build": "tsc", + "build": "tsc --project tsconfig.build.json", "test": "jest --config jest.config.ts" }, "dependencies": { - "@cityofzion/blockchain-service": "0.6.0", - "@cityofzion/bs-asteroid-sdk": "0.6.0", + "@cityofzion/blockchain-service": "0.7.0", + "@cityofzion/bs-asteroid-sdk": "0.7.0", "@cityofzion/dora-ts": "0.0.11", "@cityofzion/neon-js": "4.8.3" }, diff --git a/packages/bs-neo-legacy/src/BSNeoLegacy.ts b/packages/bs-neo-legacy/src/BSNeoLegacy.ts index 3289339..c320e55 100644 --- a/packages/bs-neo-legacy/src/BSNeoLegacy.ts +++ b/packages/bs-neo-legacy/src/BSNeoLegacy.ts @@ -3,8 +3,8 @@ import { BDSClaimable, BlockchainDataService, BlockchainService, - Claimable, - Exchange, + BSClaimable, + ExchangeDataService, Token, Network, PartialBy, @@ -12,21 +12,26 @@ import { } from '@cityofzion/blockchain-service' import { AsteroidSDK } from '@cityofzion/bs-asteroid-sdk' import { api, sc, u, wallet } from '@cityofzion/neon-js' -import { DEFAULT_URL_BY_NETWORK_TYPE, LEGACY_NETWORK_BY_NETWORK_TYPE, NATIVE_ASSETS, TOKENS } from './constants' +import { + DEFAULT_URL_BY_NETWORK_TYPE, + DERIVATION_PATH, + LEGACY_NETWORK_BY_NETWORK_TYPE, + NATIVE_ASSETS, + TOKENS, +} from './constants' import { DoraBDSNeoLegacy } from './DoraBDSNeoLegacy' -import { CryptoCompareExchange } from './exchange/CryptoCompareExchange' +import { CryptoCompareEDSNeoLegacy } from './CryptoCompareEDSNeoLegacy' -export class BSNeoLegacy implements BlockchainService, Claimable { - dataService!: BlockchainDataService & BDSClaimable +export class BSNeoLegacy implements BlockchainService, BSClaimable { + blockchainDataService!: BlockchainDataService & BDSClaimable blockchainName: BSCustomName feeToken: Token - exchange!: Exchange - tokenClaim: Token + exchangeDataService!: ExchangeDataService + claimToken: Token tokens: Token[] network!: Network legacyNetwork: string - private derivationPath: string = "m/44'/888'/0'/0/?" private keychain = new AsteroidSDK.Keychain() constructor(blockchainName: BSCustomName, network: PartialBy) { @@ -35,7 +40,7 @@ export class BSNeoLegacy implements Blockc this.blockchainName = blockchainName this.legacyNetwork = LEGACY_NETWORK_BY_NETWORK_TYPE[network.type] this.tokens = TOKENS[network.type] - this.tokenClaim = this.tokens.find(token => token.symbol === 'GAS')! + this.claimToken = this.tokens.find(token => token.symbol === 'GAS')! this.feeToken = this.tokens.find(token => token.symbol === 'GAS')! this.setNetwork(network) } @@ -48,20 +53,20 @@ export class BSNeoLegacy implements Blockc url: param.url ?? DEFAULT_URL_BY_NETWORK_TYPE[param.type], } this.network = network - this.dataService = new DoraBDSNeoLegacy(network) - this.exchange = new CryptoCompareExchange(network) + this.blockchainDataService = new DoraBDSNeoLegacy(network.type) + this.exchangeDataService = new CryptoCompareEDSNeoLegacy(network.type) } validateAddress(address: string): boolean { return wallet.isAddress(address) } - validateEncryptedKey(encryptedKey: string): boolean { - return wallet.isNEP2(encryptedKey) + validateEncrypted(key: string): boolean { + return wallet.isNEP2(key) } - validateWif(wif: string): boolean { - return wallet.isWIF(wif) + validateKey(key: string): boolean { + return wallet.isWIF(key) || wallet.isPrivateKey(key) } generateMnemonic(): string[] { @@ -72,18 +77,21 @@ export class BSNeoLegacy implements Blockc generateAccount(mnemonic: string[], index: number): Account { this.keychain.importMnemonic(mnemonic.join(' ')) - const childKey = this.keychain.generateChildKey('neo', this.derivationPath.replace('?', index.toString())) - const wif = childKey.getWIF() - const { address } = new wallet.Account(wif) - return { address, wif } + const childKey = this.keychain.generateChildKey('neo', DERIVATION_PATH.replace('?', index.toString())) + const key = childKey.getWIF() + const { address } = new wallet.Account(key) + return { address, key, type: 'wif' } } - generateAccountFromWif(wif: string): Account { - const { address } = new wallet.Account(wif) - return { address, wif } + generateAccountFromKey(key: string): Account { + const type = wallet.isWIF(key) ? 'wif' : wallet.isPrivateKey(key) ? 'privateKey' : undefined + if (!type) throw new Error('Invalid key') + + const { address } = new wallet.Account(key) + return { address, key, type } } - async decryptKey(encryptedKey: string, password: string): Promise { + async decrypt(encryptedKey: string, password: string): Promise { let BsReactNativeDecrypt: any try { @@ -91,7 +99,7 @@ export class BSNeoLegacy implements Blockc BsReactNativeDecrypt = NativeModules.BsReactNativeDecrypt } catch { const key = await wallet.decrypt(encryptedKey, password) - return this.generateAccountFromWif(key) + return this.generateAccountFromKey(key) } if (!BsReactNativeDecrypt) { @@ -99,18 +107,24 @@ export class BSNeoLegacy implements Blockc } const privateKey = await BsReactNativeDecrypt.decryptNeoLegacy(encryptedKey, password) - return this.generateAccountFromWif(privateKey) + return this.generateAccountFromKey(privateKey) } - async transfer(param: TransferParam): Promise { + async transfer({ + intent: transferIntent, + senderAccount, + priorityFee = 0, + tipIntent, + }: TransferParam): Promise { const apiProvider = new api.neoCli.instance(this.network.url) - const account = new wallet.Account(param.senderAccount.wif) - const priorityFee = param.priorityFee ?? 0 + const account = new wallet.Account(senderAccount.key) const nativeIntents: ReturnType = [] const nep5ScriptBuilder = new sc.ScriptBuilder() - for (const intent of param.intents) { + const intents = [transferIntent, ...(tipIntent ? [tipIntent] : [])] + + for (const intent of intents) { const tokenHashFixed = intent.tokenHash.replace('0x', '') const nativeAsset = NATIVE_ASSETS.find(asset => asset.hash === tokenHashFixed) @@ -158,13 +172,13 @@ export class BSNeoLegacy implements Blockc } async claim(account: Account): Promise { - const neoAccount = new wallet.Account(account.wif) + const neoAccount = new wallet.Account(account.key) - const balances = await this.dataService.getBalance(account.address) - const neoBalance = balances.find(balance => balance.symbol === 'NEO') + const balances = await this.blockchainDataService.getBalance(account.address) + const neoBalance = balances.find(balance => balance.token.symbol === 'NEO') if (!neoBalance) throw new Error('It is necessary to have NEO to claim') - const unclaimed = await this.dataService.getUnclaimed(account.address) + const unclaimed = await this.blockchainDataService.getUnclaimed(account.address) if (unclaimed <= 0) throw new Error(`Doesn't have gas to claim`) const apiProvider = new api.neoCli.instance(this.legacyNetwork) diff --git a/packages/bs-neo-legacy/src/CryptoCompareEDSNeoLegacy.ts b/packages/bs-neo-legacy/src/CryptoCompareEDSNeoLegacy.ts new file mode 100644 index 0000000..d9ef34a --- /dev/null +++ b/packages/bs-neo-legacy/src/CryptoCompareEDSNeoLegacy.ts @@ -0,0 +1,40 @@ +import { Currency, ExchangeDataService, NetworkType, TokenPricesResponse } from '@cityofzion/blockchain-service' +import axios, { AxiosInstance } from 'axios' +import { TOKENS } from './constants' + +type CryptoCompareDataResponse = { + RAW: { + [symbol: string]: { + [currency: string]: { + PRICE: number + } + } + } +} + +export class CryptoCompareEDSNeoLegacy implements ExchangeDataService { + networkType: NetworkType + private axiosInstance: AxiosInstance + + constructor(network: NetworkType) { + this.networkType = network + this.axiosInstance = axios.create({ baseURL: 'https://min-api.cryptocompare.com' }) + } + + async getTokenPrices(currency: Currency): Promise { + if (this.networkType !== 'mainnet') throw new Error('Exchange is only available on mainnet') + + const tokenSymbols = TOKENS[this.networkType].map(token => token.symbol) + const { data: prices } = await this.axiosInstance.get('/data/pricemultifull', { + params: { + fsyms: tokenSymbols.join(','), + tsyms: currency, + }, + }) + + return Object.entries(prices.RAW).map(([symbol, price]) => ({ + symbol, + amount: price[currency].PRICE, + })) + } +} diff --git a/packages/bs-neo-legacy/src/DoraBDSNeoLegacy.ts b/packages/bs-neo-legacy/src/DoraBDSNeoLegacy.ts index a3a390d..4afc51d 100644 --- a/packages/bs-neo-legacy/src/DoraBDSNeoLegacy.ts +++ b/packages/bs-neo-legacy/src/DoraBDSNeoLegacy.ts @@ -8,55 +8,63 @@ import { TransactionTransferAsset, Token, Network, + NetworkType, } from '@cityofzion/blockchain-service' import { api } from '@cityofzion/dora-ts' +import { TOKENS } from './constants' export class DoraBDSNeoLegacy implements BlockchainDataService, BDSClaimable { - network: Network + readonly networkType: NetworkType + private readonly tokenCache: Map = new Map() - constructor(network: Network) { - if (network.type === 'custom') throw new Error('Custom network is not supported for NEO Legacy') - this.network = network + constructor(networkType: NetworkType) { + if (networkType === 'custom') throw new Error('Custom network is not supported for NEO Legacy') + this.networkType = networkType } async getTransaction(hash: string): Promise { - const data = await api.NeoLegacyREST.transaction(hash, this.network.type) - const transfers = data.vout - ? (data.vout as any[]).map((transfer, _index, array) => ({ - amount: transfer.value, - from: array[array.length - 1]?.address, - hash: transfer.asset, - to: transfer.address, - type: 'asset', - })) - : [] + const data = await api.NeoLegacyREST.transaction(hash, this.networkType) + const vout: any[] = data.vout ?? [] + + const promises = vout.map>(async (transfer, _index, array) => { + const token = await this.getTokenInfo(transfer.asset) + return { + amount: Number(transfer.value), + from: array[array.length - 1]?.address, + contractHash: transfer.asset, + to: transfer.address, + type: 'token', + token, + } + }) + const transfers = await Promise.all(promises) return { hash: data.txid, block: data.block, - netfee: data.net_fee ?? '0', - sysfee: data.sys_fee ?? '0', - totfee: (Number(data.sys_fee) + Number(data.net_fee)).toString(), - time: data.time, + fee: Number(data.sys_fee ?? 0) + Number(data.net_fee ?? 0), + time: Number(data.time), notifications: [], //neoLegacy doesn't have notifications transfers, } } - async getHistoryTransactions(address: string, page: number = 1): Promise { - const data = await api.NeoLegacyREST.getAddressAbstracts(address, page, this.network.type) + async getTransactionsByAddress(address: string, page: number = 1): Promise { + const data = await api.NeoLegacyREST.getAddressAbstracts(address, page, this.networkType) const transactions = new Map() - data.entries.forEach(entry => { + const promises = data.entries.map(async entry => { if (entry.address_from !== address && entry.address_to !== address) return + const token = await this.getTokenInfo(entry.asset) const transfer: TransactionTransferAsset = { - amount: entry.amount.toString(), + amount: Number(entry.amount), from: entry.address_from ?? 'Mint', to: entry.address_to ?? 'Burn', - type: 'asset', + type: 'token', + contractHash: entry.asset, + token, } - const existingTransaction = transactions.get(entry.txid) if (existingTransaction) { existingTransaction.transfers.push(transfer) @@ -66,14 +74,12 @@ export class DoraBDSNeoLegacy implements BlockchainDataService, BDSClaimable { transactions.set(entry.txid, { block: entry.block_height, hash: entry.txid, - netfee: '0', - sysfee: '0', - totfee: '0', - time: entry.time.toString(), + time: entry.time, transfers: [transfer], notifications: [], }) }) + await Promise.all(promises) return { totalCount: data.total_entries, @@ -82,7 +88,7 @@ export class DoraBDSNeoLegacy implements BlockchainDataService, BDSClaimable { } async getContract(contractHash: string): Promise { - const response = await api.NeoLegacyREST.contract(contractHash, this.network.type) + const response = await api.NeoLegacyREST.contract(contractHash, this.networkType) return { hash: response.hash, name: response.name, @@ -91,28 +97,35 @@ export class DoraBDSNeoLegacy implements BlockchainDataService, BDSClaimable { } async getTokenInfo(tokenHash: string): Promise { - const data = await api.NeoLegacyREST.asset(tokenHash, this.network.type) + const localToken = TOKENS[this.networkType].find(token => token.hash === tokenHash) + if (localToken) return localToken - return { + if (this.tokenCache.has(tokenHash)) { + return this.tokenCache.get(tokenHash)! + } + + const data = await api.NeoLegacyREST.asset(tokenHash, this.networkType) + const token = { decimals: data.decimals, symbol: data.symbol, hash: data.scripthash, name: data.name, } + + this.tokenCache.set(tokenHash, token) + + return token } async getBalance(address: string): Promise { - const data = await api.NeoLegacyREST.balance(address, this.network.type) + const data = await api.NeoLegacyREST.balance(address, this.networkType) const promises = data.map>(async balance => { - const { decimals } = await this.getTokenInfo(balance.asset) + const token = await this.getTokenInfo(balance.asset) return { - hash: balance.asset, amount: Number(balance.balance), - name: balance.asset_name, - symbol: balance.symbol, - decimals, + token, } }) @@ -122,7 +135,7 @@ export class DoraBDSNeoLegacy implements BlockchainDataService, BDSClaimable { } async getUnclaimed(address: string): Promise { - const { unclaimed } = await api.NeoLegacyREST.getUnclaimed(address, this.network.type) + const { unclaimed } = await api.NeoLegacyREST.getUnclaimed(address, this.networkType) return unclaimed } } diff --git a/packages/bs-neo-legacy/src/__tests__/BDSNeoLegacy.spec.ts b/packages/bs-neo-legacy/src/__tests__/BDSNeoLegacy.spec.ts index 203936e..b53a3c5 100644 --- a/packages/bs-neo-legacy/src/__tests__/BDSNeoLegacy.spec.ts +++ b/packages/bs-neo-legacy/src/__tests__/BDSNeoLegacy.spec.ts @@ -1,7 +1,8 @@ import { BDSClaimable, BlockchainDataService } from '@cityofzion/blockchain-service' import { DoraBDSNeoLegacy } from '../DoraBDSNeoLegacy' +import { TOKENS } from '../constants' -let doraBDSNeoLegacy = new DoraBDSNeoLegacy({ type: 'testnet', url: 'http://seed5.ngd.network:20332' }) +let doraBDSNeoLegacy = new DoraBDSNeoLegacy('testnet') describe('BDSNeoLegacy', () => { it.each([doraBDSNeoLegacy])('Should be able to get transaction - %s', async (bdsNeoLegacy: BlockchainDataService) => { @@ -14,16 +15,14 @@ describe('BDSNeoLegacy', () => { notifications: [], transfers: expect.arrayContaining([ expect.objectContaining({ - amount: expect.any(String), + amount: expect.any(Number), from: expect.any(String), to: expect.any(String), - type: 'asset', + type: 'token', }), ]), - time: expect.any(String), - netfee: expect.any(String), - sysfee: expect.any(String), - totfee: expect.any(String), + time: expect.any(Number), + fee: expect.any(Number), }) ) }) @@ -33,9 +32,9 @@ describe('BDSNeoLegacy', () => { async (bdsNeoLegacy: BlockchainDataService) => { const address = 'AeGgZTTWPzyVtNiQRcpngkV75Xip1hznmi' try { - const transaction = await bdsNeoLegacy.getHistoryTransactions(address, 1) - expect(transaction).toEqual( - expect.arrayContaining( + const response = await bdsNeoLegacy.getTransactionsByAddress(address, 1) + response.transactions.forEach(transaction => { + expect(transaction).toEqual( expect.objectContaining({ block: expect.any(Number), hash: expect.any(String), @@ -52,19 +51,17 @@ describe('BDSNeoLegacy', () => { ]), transfers: expect.arrayContaining([ expect.objectContaining({ - amount: expect.any(String), + amount: expect.any(Number), from: expect.any(String), to: expect.any(String), type: 'asset', }), ]), - time: expect.any(String), - netfee: expect.any(String), - sysfee: expect.any(String), - totfee: expect.any(String), + time: expect.any(Number), + fee: expect.any(Number), }) ) - ) + }) } catch {} } ) @@ -93,17 +90,18 @@ describe('BDSNeoLegacy', () => { it.each([doraBDSNeoLegacy])('Should be able to get balance - %s', async (bdsNeoLegacy: BlockchainDataService) => { const address = 'AeGgZTTWPzyVtNiQRcpngkV75Xip1hznmi' const balance = await bdsNeoLegacy.getBalance(address) - expect(balance).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: expect.any(Number), + + balance.forEach(balance => { + expect(balance).toEqual({ + amount: expect.any(Number), + token: { hash: expect.any(String), name: expect.any(String), symbol: expect.any(String), decimals: expect.any(Number), - }), - ]) - ) + }, + }) + }) }) it.each([doraBDSNeoLegacy])( diff --git a/packages/bs-neo-legacy/src/__tests__/BSNeoLegacy.spec.ts b/packages/bs-neo-legacy/src/__tests__/BSNeoLegacy.spec.ts index 07c7d70..ee6d4f4 100644 --- a/packages/bs-neo-legacy/src/__tests__/BSNeoLegacy.spec.ts +++ b/packages/bs-neo-legacy/src/__tests__/BSNeoLegacy.spec.ts @@ -21,16 +21,16 @@ describe('BSNeoLegacy', () => { const validEncryptedKey = '6PYSsRjFn1v5uu79h5vXGZEYvvRkioHmd1Fd5bUyVp9Gt2wJcLKWHgD6Hy' const invalidEncryptedKey = 'invalid encrypted key' - expect(bsNeoLegacy.validateEncryptedKey(validEncryptedKey)).toBeTruthy() - expect(bsNeoLegacy.validateEncryptedKey(invalidEncryptedKey)).toBeFalsy() + expect(bsNeoLegacy.validateEncrypted(validEncryptedKey)).toBeTruthy() + expect(bsNeoLegacy.validateEncrypted(invalidEncryptedKey)).toBeFalsy() }) it('Should be able to validate a wif', () => { const validWif = 'L4ZnhLegkFV9FTys1wBJDHUykn5hLnr15cPqvfuy4E1kzWTE6iRM' const invalidWif = 'invalid wif' - expect(bsNeoLegacy.validateWif(validWif)).toBeTruthy() - expect(bsNeoLegacy.validateWif(invalidWif)).toBeFalsy() + expect(bsNeoLegacy.validateKey(validWif)).toBeTruthy() + expect(bsNeoLegacy.validateKey(invalidWif)).toBeFalsy() }) it('Should be able to generate a mnemonic', () => { @@ -45,14 +45,14 @@ describe('BSNeoLegacy', () => { const account = bsNeoLegacy.generateAccount(mnemonic, 0) expect(bsNeoLegacy.validateAddress(account.address)).toBeTruthy() - expect(bsNeoLegacy.validateWif(account.wif)).toBeTruthy() + expect(bsNeoLegacy.validateKey(account.key)).toBeTruthy() }) it('Should be able to generate a account from wif', () => { const mnemonic = bsNeoLegacy.generateMnemonic() const account = bsNeoLegacy.generateAccount(mnemonic, 0) - const accountFromWif = bsNeoLegacy.generateAccountFromWif(account.wif) + const accountFromWif = bsNeoLegacy.generateAccountFromKey(account.key) expect(account).toEqual(accountFromWif) }) @@ -60,27 +60,25 @@ describe('BSNeoLegacy', () => { const mnemonic = bsNeoLegacy.generateMnemonic() const account = bsNeoLegacy.generateAccount(mnemonic, 0) const password = 'TestPassword' - const encryptedKey = await wallet.encrypt(account.wif, password) - const decryptedAccount = await bsNeoLegacy.decryptKey(encryptedKey, password) + const encryptedKey = await wallet.encrypt(account.key, password) + const decryptedAccount = await bsNeoLegacy.decrypt(encryptedKey, password) expect(decryptedAccount).toEqual(account) }, 10000) it.skip('Should be able to transfer a native asset', async () => { - const account = bsNeoLegacy.generateAccountFromWif(process.env.TESTNET_PRIVATE_KEY as string) - const balance = await bsNeoLegacy.dataService.getBalance(account.address) - const gasBalance = balance.find(b => b.symbol === 'GAS')! + const account = bsNeoLegacy.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + const balance = await bsNeoLegacy.blockchainDataService.getBalance(account.address) + const gasBalance = balance.find(b => b.token.symbol === 'GAS')! expect(gasBalance?.amount).toBeGreaterThan(0.00000001) const transactionHash = await bsNeoLegacy.transfer({ senderAccount: account, - intents: [ - { - amount: 0.00000001, - receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', - tokenHash: gasBalance.hash, - tokenDecimals: gasBalance.decimals, - }, - ], + intent: { + amount: 0.00000001, + receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', + tokenHash: gasBalance.token.hash, + tokenDecimals: gasBalance.token.decimals, + }, }) expect(transactionHash).toEqual(expect.any(String)) @@ -88,51 +86,47 @@ describe('BSNeoLegacy', () => { it.skip('Should be able to transfer a nep5 asset', async () => { bsNeoLegacy.setNetwork({ type: 'mainnet', url: 'http://seed9.ngd.network:10332' }) - const account = bsNeoLegacy.generateAccountFromWif(process.env.TESTNET_PRIVATE_KEY as string) - const balance = await bsNeoLegacy.dataService.getBalance(account.address) - const LXBalance = balance.find(b => b.symbol === 'LX')! + const account = bsNeoLegacy.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + const balance = await bsNeoLegacy.blockchainDataService.getBalance(account.address) + const LXBalance = balance.find(item => item.token.symbol === 'LX')! expect(LXBalance?.amount).toBeGreaterThan(0.00000001) const transactionHash = await bsNeoLegacy.transfer({ senderAccount: account, - intents: [ - { - amount: 0.00000001, - receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', - tokenHash: LXBalance.hash, - tokenDecimals: LXBalance.decimals, - }, - ], + intent: { + amount: 0.00000001, + receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', + tokenHash: LXBalance.token.hash, + tokenDecimals: LXBalance.token.decimals, + }, }) expect(transactionHash).toEqual(expect.any(String)) }) - it.skip('Should be able to transfer a nep5 asset with a native asset', async () => { + it.skip('Should be able to transfer a asset with tip', async () => { bsNeoLegacy.setNetwork({ type: 'mainnet', url: 'http://seed9.ngd.network:10332' }) - const account = bsNeoLegacy.generateAccountFromWif(process.env.TESTNET_PRIVATE_KEY as string) - const balance = await bsNeoLegacy.dataService.getBalance(account.address) - const LXBalance = balance.find(b => b.symbol === 'LX')! + const account = bsNeoLegacy.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + const balance = await bsNeoLegacy.blockchainDataService.getBalance(account.address) + const LXBalance = balance.find(item => item.token.symbol === 'LX')! expect(LXBalance?.amount).toBeGreaterThan(0.00000001) - const gasBalance = balance.find(b => b.symbol === bsNeoLegacy.feeToken.symbol)! + const gasBalance = balance.find(item => item.token.symbol === bsNeoLegacy.feeToken.symbol)! expect(gasBalance?.amount).toBeGreaterThan(0.00000001) const transactionHash = await bsNeoLegacy.transfer({ senderAccount: account, - intents: [ - { - amount: 0.00000001, - receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', - tokenHash: LXBalance.hash, - tokenDecimals: LXBalance.decimals, - }, - { - amount: 0.00000001, - receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', - tokenHash: gasBalance.hash, - tokenDecimals: gasBalance.decimals, - }, - ], + intent: { + amount: 0.00000001, + receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', + tokenHash: LXBalance.token.hash, + tokenDecimals: LXBalance.token.decimals, + }, + tipIntent: { + amount: 0.00000001, + receiverAddress: 'AQEQdmCcitFbE6oJU5Epa7dNxhTkCmTZST', + tokenHash: gasBalance.token.hash, + tokenDecimals: gasBalance.token.decimals, + }, }) expect(transactionHash).toEqual(expect.any(String)) diff --git a/packages/bs-neo-legacy/src/__tests__/CryptoCompareExchange.spec.ts b/packages/bs-neo-legacy/src/__tests__/CryptoCompareExchange.spec.ts index 6eee70e..3999044 100644 --- a/packages/bs-neo-legacy/src/__tests__/CryptoCompareExchange.spec.ts +++ b/packages/bs-neo-legacy/src/__tests__/CryptoCompareExchange.spec.ts @@ -1,27 +1,48 @@ import { Currency } from '@cityofzion/blockchain-service' import { BSNeoLegacy } from '../BSNeoLegacy' +import { CryptoCompareEDSNeoLegacy } from '../CryptoCompareEDSNeoLegacy' -let bsNeoLegacy: BSNeoLegacy +let cryptoCompareEDSNeoLegacy: CryptoCompareEDSNeoLegacy -describe('CryptoCompare', () => { +describe('FlamingoEDSNeo3', () => { beforeAll(() => { - bsNeoLegacy = new BSNeoLegacy('neoLegacy', { type: 'mainnet', url: '"https://mainnet1.neo2.coz.io:443' }) + cryptoCompareEDSNeoLegacy = new CryptoCompareEDSNeoLegacy('mainnet') }) + it('Should return a list with prices of tokens using USD', async () => { - const currency: Currency = 'USD' - const tokenPriceList = await bsNeoLegacy.exchange.getTokenPrices(currency) - expect(tokenPriceList.length).toBeGreaterThan(0) + const tokenPriceList = await cryptoCompareEDSNeoLegacy.getTokenPrices('USD') + + tokenPriceList.forEach(tokenPrice => { + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) }) it('Should return a list with prices of tokens using BRL', async () => { - const currency: Currency = 'BRL' - const tokenPriceList = await bsNeoLegacy.exchange.getTokenPrices(currency) - expect(tokenPriceList.length).toBeGreaterThan(0) + const tokenPriceListInUSD = await cryptoCompareEDSNeoLegacy.getTokenPrices('USD') + const tokenPriceList = await cryptoCompareEDSNeoLegacy.getTokenPrices('BRL') + + tokenPriceList.forEach((tokenPrice, index) => { + expect(tokenPrice.amount).toBeGreaterThan(tokenPriceListInUSD[index].amount) + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) }) it('Should return a list with prices of tokens using EUR', async () => { - const currency: Currency = 'EUR' - const tokenPriceList = await bsNeoLegacy.exchange.getTokenPrices(currency) - expect(tokenPriceList.length).toBeGreaterThan(0) + const tokenPriceListInUSD = await cryptoCompareEDSNeoLegacy.getTokenPrices('USD') + const tokenPriceList = await cryptoCompareEDSNeoLegacy.getTokenPrices('EUR') + + tokenPriceList.forEach((tokenPrice, index) => { + expect(tokenPrice.amount).toBeLessThan(tokenPriceListInUSD[index].amount) + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) }) }) diff --git a/packages/bs-neo-legacy/src/constants.ts b/packages/bs-neo-legacy/src/constants.ts index d99426a..82e3614 100644 --- a/packages/bs-neo-legacy/src/constants.ts +++ b/packages/bs-neo-legacy/src/constants.ts @@ -19,3 +19,5 @@ export const DEFAULT_URL_BY_NETWORK_TYPE: Record, mainnet: 'http://seed9.ngd.network:10332', testnet: 'http://seed5.ngd.network:20332', } + +export const DERIVATION_PATH = "m/44'/888'/0'/0/?" diff --git a/packages/bs-neo-legacy/src/exchange/CryptoCompareExchange.ts b/packages/bs-neo-legacy/src/exchange/CryptoCompareExchange.ts deleted file mode 100644 index 5b38527..0000000 --- a/packages/bs-neo-legacy/src/exchange/CryptoCompareExchange.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Currency, Exchange, Network, TokenPricesResponse } from '@cityofzion/blockchain-service' -import axios, { AxiosInstance } from 'axios' -import {TOKENS} from '../constants' - -type CryptoCompareDataResponse = { - RAW: { - [symbol: string]: { - [currency: string]: { - PRICE: number - } - } - } -} - -export class CryptoCompareExchange implements Exchange { - network: Network; - private axiosInstance: AxiosInstance - - constructor(network: Network) { - this.network = network - this.axiosInstance = axios.create({ baseURL: "https://min-api.cryptocompare.com" }) - } - - async getTokenPrices(currency: Currency): Promise { - if (this.network.type !== 'mainnet') throw new Error('Exchange is only available on mainnet') - const tokenSymbols = TOKENS[this.network.type].map(token => token.symbol) - const { data: prices } = await this.axiosInstance.get("/data/pricemultifull", { - params: { - fsyms: tokenSymbols.join(','), - tsyms: currency - } - }) - return Object.entries(prices.RAW).map(([symbol, price]) => ({ - symbol, - amount: price[currency].PRICE - })) - } - -} \ No newline at end of file diff --git a/packages/bs-neo-legacy/src/index.ts b/packages/bs-neo-legacy/src/index.ts index 1f544b9..247e7d0 100644 --- a/packages/bs-neo-legacy/src/index.ts +++ b/packages/bs-neo-legacy/src/index.ts @@ -1,3 +1,4 @@ export * from './BSNeoLegacy' export * from './DoraBDSNeoLegacy' export * from './constants' +export * from './CryptoCompareEDSNeoLegacy' diff --git a/packages/bs-neo-legacy/tsconfig.build.json b/packages/bs-neo-legacy/tsconfig.build.json new file mode 100644 index 0000000..4dc23fb --- /dev/null +++ b/packages/bs-neo-legacy/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__"] +} diff --git a/packages/bs-neo-legacy/tsconfig.json b/packages/bs-neo-legacy/tsconfig.json index eb55a59..77cf2b7 100644 --- a/packages/bs-neo-legacy/tsconfig.json +++ b/packages/bs-neo-legacy/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "./dist" }, "include": ["src"], - "exclude": ["node_modules", "src/__tests__"], + "exclude": ["node_modules"], "typedocOptions": { "entryPoints": ["./src/index.ts"], "out": "docs", diff --git a/packages/bs-neo3/package.json b/packages/bs-neo3/package.json index b72c350..d5ccf9f 100644 --- a/packages/bs-neo3/package.json +++ b/packages/bs-neo3/package.json @@ -7,7 +7,7 @@ "author": "Coz", "license": "MIT", "scripts": { - "build": "tsc", + "build": "tsc --project tsconfig.build.json", "test": "jest --config jest.config.ts" }, "dependencies": { @@ -17,10 +17,10 @@ "@cityofzion/neo3-invoker": "1.6.0", "@cityofzion/neon-parser": "1.6.2", "@cityofzion/neo3-parser": "1.6.0", - "@cityofzion/blockchain-service": "0.6.0", - "@cityofzion/bs-asteroid-sdk": "0.6.0", + "@cityofzion/blockchain-service": "0.7.0", + "@cityofzion/bs-asteroid-sdk": "0.7.0", "@cityofzion/dora-ts": "0.0.11", - "querystringify": "~2.2.0" + "query-string": "7.1.3" }, "devDependencies": { "ts-node": "10.9.1", @@ -28,7 +28,6 @@ "jest": "29.6.2", "ts-jest": "29.1.1", "@types/jest": "29.5.3", - "dotenv": "16.3.1", - "@types/querystringify": "~2.0.0" + "dotenv": "16.3.1" } } \ No newline at end of file diff --git a/packages/bs-neo3/src/BSNeo3.ts b/packages/bs-neo3/src/BSNeo3.ts index c4c18dc..68eaec8 100644 --- a/packages/bs-neo3/src/BSNeo3.ts +++ b/packages/bs-neo3/src/BSNeo3.ts @@ -1,74 +1,54 @@ import { BlockchainDataService, BlockchainService, - Claimable, + BSClaimable, Account, - Exchange, + ExchangeDataService, BDSClaimable, Token, - NeoNameService, - CalculateTransferFeeResponse, + BSWithNameService, Network, PartialBy, TransferParam, - CalculableFee, + BSCalculableFee, NftDataService, - NFTResponse, - NFTSResponse, + BSWithNft, } from '@cityofzion/blockchain-service' import { api, u, wallet } from '@cityofzion/neon-js' import Neon from '@cityofzion/neon-core' - import { NeonInvoker } from '@cityofzion/neon-invoker' import { NeonParser } from '@cityofzion/neon-parser' import { ABI_TYPES } from '@cityofzion/neo3-parser' import { ContractInvocation } from '@cityofzion/neo3-invoker' + import { RPCBDSNeo3 } from './RpcBDSNeo3' import { DoraBDSNeo3 } from './DoraBDSNeo3' -import { DEFAULT_URL_BY_NETWORK_TYPE, NEO_NS_HASH, TOKENS } from './constants' -import { FlamingoExchange } from './FlamingoExchange' -import { GhostMarketNDS } from './GhostMarketNDS' +import { DEFAULT_URL_BY_NETWORK_TYPE, DERIVATION_PATH, NEO_NS_HASH, TOKENS } from './constants' +import { FlamingoEDSNeo3 } from './FlamingoEDSNeo3' +import { GhostMarketNDSNeo3 } from './GhostMarketNDSNeo3' import { AsteroidSDK } from '@cityofzion/bs-asteroid-sdk' export class BSNeo3 - implements BlockchainService, Claimable, NeoNameService, CalculableFee, NftDataService + implements BlockchainService, BSClaimable, BSWithNameService, BSCalculableFee, BSWithNft { blockchainName: BSCustomName - dataService!: BlockchainDataService & BDSClaimable + blockchainDataService!: BlockchainDataService & BDSClaimable + nftDataService!: NftDataService feeToken: Token - exchange!: Exchange - tokenClaim: Token + exchangeDataService!: ExchangeDataService + claimToken: Token tokens: Token[] network!: Network - private derivationPath: string = "m/44'/888'/0'/0/?" private keychain = new AsteroidSDK.Keychain() - private ghostMarket: GhostMarketNDS constructor(blockchainName: BSCustomName, network: PartialBy) { this.blockchainName = blockchainName this.tokens = TOKENS[network.type] this.feeToken = this.tokens.find(token => token.symbol === 'GAS')! - this.tokenClaim = this.tokens.find(token => token.symbol === 'GAS')! + this.claimToken = this.tokens.find(token => token.symbol === 'GAS')! this.setNetwork(network) - this.ghostMarket = new GhostMarketNDS(this) - } - - async getNFTS(address: string, page: number = 1): Promise { - const nftPageLimit = 18 - return await this.ghostMarket.getNFTS({ - owners: [address], - size: nftPageLimit, - page, - getTotal: true, - }) - } - async getNFT(tokenID: string, hash: string): Promise { - return await this.ghostMarket.getNFT({ - contract: hash, - ['tokenIds[]']: [tokenID], - }) } setNetwork(param: PartialBy) { @@ -79,27 +59,28 @@ export class BSNeo3 this.network = network if (network.type === 'custom') { - this.dataService = new RPCBDSNeo3(network) + this.blockchainDataService = new RPCBDSNeo3(network, this.feeToken, this.claimToken) } else { - this.dataService = new DoraBDSNeo3(network) + this.blockchainDataService = new DoraBDSNeo3(network, this.feeToken, this.claimToken) } - this.exchange = new FlamingoExchange(network) + this.exchangeDataService = new FlamingoEDSNeo3(network.type) + this.nftDataService = new GhostMarketNDSNeo3(network.type) } validateAddress(address: string): boolean { return wallet.isAddress(address, 53) } - validateEncryptedKey(encryptedKey: string): boolean { + validateEncrypted(encryptedKey: string): boolean { return wallet.isNEP2(encryptedKey) } - validateWif(wif: string): boolean { - return wallet.isWIF(wif) + validateKey(key: string): boolean { + return wallet.isWIF(key) || wallet.isPrivateKey(key) } - validateNNSFormat(domainName: string): boolean { + validateNameServiceDomainFormat(domainName: string): boolean { if (!domainName.endsWith('.neo')) return false return true } @@ -112,18 +93,21 @@ export class BSNeo3 generateAccount(mnemonic: string[], index: number): Account { this.keychain.importMnemonic(mnemonic.join(' ')) - const childKey = this.keychain.generateChildKey('neo', this.derivationPath.replace('?', index.toString())) - const wif = childKey.getWIF() - const { address } = new wallet.Account(wif) - return { address, wif } + const childKey = this.keychain.generateChildKey('neo', DERIVATION_PATH.replace('?', index.toString())) + const key = childKey.getWIF() + const { address } = new wallet.Account(key) + return { address, key, type: 'wif' } } - generateAccountFromWif(wif: string): Account { - const { address } = new wallet.Account(wif) - return { address, wif } + generateAccountFromKey(key: string): Account { + const type = wallet.isWIF(key) ? 'wif' : wallet.isPrivateKey(key) ? 'privateKey' : undefined + if (!type) throw new Error('Invalid key') + + const { address } = new wallet.Account(key) + return { address, key, type } } - async decryptKey(encryptedKey: string, password: string): Promise { + async decrypt(encryptedKey: string, password: string): Promise { let BsReactNativeDecrypt: any try { @@ -131,7 +115,7 @@ export class BSNeo3 BsReactNativeDecrypt = NativeModules.BsReactNativeDecrypt } catch { const key = await wallet.decrypt(encryptedKey, password) - return this.generateAccountFromWif(key) + return this.generateAccountFromKey(key) } if (!BsReactNativeDecrypt) { @@ -139,11 +123,11 @@ export class BSNeo3 } const privateKey = await BsReactNativeDecrypt.decryptNeo3(encryptedKey, password) - return this.generateAccountFromWif(privateKey) + return this.generateAccountFromKey(privateKey) } - async calculateTransferFee(param: TransferParam): Promise { - const account = new wallet.Account(param.senderAccount.wif) + async calculateTransferFee(param: TransferParam): Promise { + const account = new wallet.Account(param.senderAccount.key) const invoker = await NeonInvoker.init({ rpcAddress: this.network.url, account, @@ -151,20 +135,16 @@ export class BSNeo3 const invocations = this.buildTransferInvocation(param, account) - const { networkFee, systemFee, total } = await invoker.calculateFee({ + const { networkFee, systemFee } = await invoker.calculateFee({ invocations, signers: [], }) - return { - total, - networkFee: Number(networkFee.toDecimal(8)), - systemFee: Number(systemFee.toDecimal(8)), - } + return networkFee.add(systemFee).toDecimal(this.feeToken.decimals) } async transfer(param: TransferParam): Promise { - const account = new wallet.Account(param.senderAccount.wif) + const account = new wallet.Account(param.senderAccount.key) const invoker = await NeonInvoker.init({ rpcAddress: this.network.url, account, @@ -181,7 +161,7 @@ export class BSNeo3 } async claim(account: Account): Promise { - const neoAccount = new wallet.Account(account.wif) + const neoAccount = new wallet.Account(account.key) const facade = await api.NetworkFacade.fromConfig({ node: this.network.url }) const transactionHash = await facade.claimGas(neoAccount, { @@ -191,7 +171,7 @@ export class BSNeo3 return transactionHash } - async getOwnerOfNNS(domainName: string): Promise { + async resolveNameServiceDomain(domainName: string): Promise { const parser = NeonParser const invoker = await NeonInvoker.init({ rpcAddress: this.network.url }) const response = await invoker.testInvoke({ @@ -215,22 +195,29 @@ export class BSNeo3 return address } - private buildTransferInvocation(param: TransferParam, account: Neon.wallet.Account): ContractInvocation[] { - const invocations: ContractInvocation[] = param.intents.map(intent => ({ - operation: 'transfer', - scriptHash: intent.tokenHash, - args: [ - { type: 'Hash160', value: account.address }, - { type: 'Hash160', value: intent.receiverAddress }, - { - type: 'Integer', - value: intent.tokenDecimals - ? u.BigInteger.fromDecimal(intent.amount, intent.tokenDecimals).toString() - : intent.amount, - }, - { type: 'Any', value: '' }, - ], - })) + private buildTransferInvocation( + { intent, tipIntent }: TransferParam, + account: Neon.wallet.Account + ): ContractInvocation[] { + const intents = [intent, ...(tipIntent ? [tipIntent] : [])] + + const invocations: ContractInvocation[] = intents.map(intent => { + return { + operation: 'transfer', + scriptHash: intent.tokenHash, + args: [ + { type: 'Hash160', value: account.address }, + { type: 'Hash160', value: intent.receiverAddress }, + { + type: 'Integer', + value: intent.tokenDecimals + ? u.BigInteger.fromDecimal(intent.amount, intent.tokenDecimals).toString() + : intent.amount, + }, + { type: 'Any', value: '' }, + ], + } + }) return invocations } diff --git a/packages/bs-neo3/src/DoraBDSNeo3.ts b/packages/bs-neo3/src/DoraBDSNeo3.ts index e30670d..176d721 100644 --- a/packages/bs-neo3/src/DoraBDSNeo3.ts +++ b/packages/bs-neo3/src/DoraBDSNeo3.ts @@ -12,16 +12,17 @@ import { import { wallet, u } from '@cityofzion/neon-js' import { api } from '@cityofzion/dora-ts' import { RPCBDSNeo3 } from './RpcBDSNeo3' +import { TOKENS } from './constants' export class DoraBDSNeo3 extends RPCBDSNeo3 { readonly network: Network - constructor(network: Network) { + constructor(network: Network, feeToken: Token, claimToken: Token) { if (network.type === 'custom') { throw new Error('DoraBDSNeo3 does not support custom networks') } - super(network) + super(network, feeToken, claimToken) this.network = network } @@ -29,73 +30,77 @@ export class DoraBDSNeo3 extends RPCBDSNeo3 { const data = await api.NeoRest.transaction(hash, this.network.type) return { block: data.block, - time: data.time, + time: Number(data.time), hash: data.hash, - netfee: data.netfee, - sysfee: data.sysfee, - totfee: u.BigInteger.fromNumber(data.netfee ?? 0) - .add(u.BigInteger.fromNumber(data.sysfee ?? 0)) - .toString(), + fee: Number( + u.BigInteger.fromNumber(data.netfee ?? 0) + .add(u.BigInteger.fromNumber(data.sysfee ?? 0)) + .toDecimal(this.feeToken.decimals) + ), notifications: [], transfers: [], } } - async getHistoryTransactions(address: string, page: number = 1): Promise { + async getTransactionsByAddress(address: string, page: number = 1): Promise { const data = await api.NeoRest.addressTXFull(address, page, this.network.type) - const transactions = data.items.map(item => { - const transfers = item.notifications - .map(notification => { - const { event_name: eventName } = notification - const state = notification.state as any - - if (eventName !== 'Transfer') return null - - const isAsset = state.length === 3 - const isNFT = state.length === 4 - if (!isAsset && !isNFT) return null - - const [{ value: from }, { value: to }] = state - const convertedFrom = from ? this.convertByteStringToAddress(from) : 'Mint' - const convertedTo = to ? this.convertByteStringToAddress(to) : 'Burn' + const transactions = await Promise.all( + data.items.map(async (item): Promise => { + const filteredTransfers = item.notifications.filter( + item => item.event_name === 'Transfer' && (item.state.value.length === 3 || item.state.value.length === 4) + ) + const transferPromises = filteredTransfers.map>( + async ({ contract: contractHash, state: { value: properties } }) => { + const isAsset = properties.length === 3 + + const from = properties[0].value + const to = properties[1].value + const convertedFrom = from ? this.convertByteStringToAddress(from) : 'Mint' + const convertedTo = to ? this.convertByteStringToAddress(to) : 'Burn' + + if (isAsset) { + const token = await this.getTokenInfo(contractHash) + const [, , { value: amount }] = properties + return { + amount: Number(u.BigInteger.fromNumber(amount).toDecimal(token.decimals ?? 0)), + from: from, + to: convertedTo, + contractHash, + type: 'token', + token, + } + } - if (isAsset) { - const [, , { value: amount }] = state return { - amount: amount, from: convertedFrom, to: convertedTo, - type: 'asset', + tokenId: this.convertByteStringToInteger(properties[4].value), + contractHash, + type: 'nft', } } - - const [, , , { value: tokenId }] = state - const convertedTokenId = this.convertByteStringToInteger(tokenId) - return { - from: convertedFrom, - to: convertedTo, - tokenId: convertedTokenId, - type: 'nft', - } - }) - .filter((transfer): transfer is TransactionTransferAsset | TransactionTransferNft => transfer !== null) - - const notifications = item.notifications.map(notification => ({ - eventName: notification.event_name, - state: notification.state as any, - })) - - return { - block: item.block, - time: item.time, - hash: item.hash, - netfee: item.netfee, - sysfee: item.sysfee, - totfee: (Number(item.sysfee) + Number(item.netfee)).toString(), - transfers, - notifications, - } - }) + ) + const transfers = await Promise.all(transferPromises) + + const notifications = item.notifications.map(notification => ({ + eventName: notification.event_name, + state: notification.state as any, + })) + + return { + block: item.block, + time: Number(item.time), + hash: item.hash, + fee: Number( + u.BigInteger.fromNumber(item.netfee ?? 0) + .add(u.BigInteger.fromNumber(item.sysfee ?? 0)) + .toDecimal(this.feeToken.decimals) + ), + transfers, + notifications, + } + }) + ) return { totalCount: data.totalCount, @@ -113,27 +118,33 @@ export class DoraBDSNeo3 extends RPCBDSNeo3 { } async getTokenInfo(tokenHash: string): Promise { - const { decimals, symbol, name, scripthash } = await api.NeoRest.asset(tokenHash, this.network.type) + const localToken = TOKENS[this.network.type].find(token => token.hash === tokenHash) + if (localToken) return localToken - return { + if (this.tokenCache.has(tokenHash)) { + return this.tokenCache.get(tokenHash)! + } + + const { decimals, symbol, name, scripthash } = await api.NeoRest.asset(tokenHash, this.network.type) + const token = { decimals: Number(decimals), symbol, name, hash: scripthash, } + this.tokenCache.set(tokenHash, token) + + return token } async getBalance(address: string): Promise { - const data = await api.NeoRest.balance(address, this.network.type) + const response = await api.NeoRest.balance(address, this.network.type) - const promises = data.map>(async balance => { - const tokenInfo = await api.NeoRest.asset(balance.asset, this.network.type) + const promises = response.map>(async balance => { + const token = await this.getTokenInfo(balance.asset) return { amount: Number(balance.balance), - hash: balance.asset, - name: balance.asset_name, - symbol: balance.symbol, - decimals: Number(tokenInfo.decimals), + token, } }) const balances = await Promise.all(promises) diff --git a/packages/bs-neo3/src/FlamingoEDSNeo3.ts b/packages/bs-neo3/src/FlamingoEDSNeo3.ts new file mode 100644 index 0000000..3142fc7 --- /dev/null +++ b/packages/bs-neo3/src/FlamingoEDSNeo3.ts @@ -0,0 +1,45 @@ +import { + Currency, + ExchangeDataService, + Network, + NetworkType, + TokenPricesResponse, +} from '@cityofzion/blockchain-service' +import axios, { AxiosInstance } from 'axios' + +type FlamingoTokenInfoPricesResponse = { + symbol: string + usd_price: number +}[] + +export class FlamingoEDSNeo3 implements ExchangeDataService { + readonly networkType: NetworkType + private axiosInstance: AxiosInstance + + constructor(networkType: NetworkType) { + this.networkType = networkType + this.axiosInstance = axios.create({ baseURL: 'https://api.flamingo.finance' }) + } + + async getTokenPrices(currency: Currency): Promise { + if (this.networkType !== 'mainnet') throw new Error('Exchange is only available on mainnet') + + const { data: prices } = await this.axiosInstance.get('/token-info/prices') + + let currencyRatio: number = 1 + + if (currency !== 'USD') { + currencyRatio = await this.getCurrencyRatio(currency) + } + + return prices.map(price => ({ + amount: price.usd_price * currencyRatio, + symbol: price.symbol, + })) + } + + private async getCurrencyRatio(currency: Currency): Promise { + const { data } = await this.axiosInstance.get(`/fiat/exchange-rate?pair=USD_${currency}`) + return data + } +} diff --git a/packages/bs-neo3/src/FlamingoExchange.ts b/packages/bs-neo3/src/FlamingoExchange.ts deleted file mode 100644 index 493070e..0000000 --- a/packages/bs-neo3/src/FlamingoExchange.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Currency, Exchange, Network, TokenPricesResponse } from '@cityofzion/blockchain-service' -import axios, { AxiosInstance } from 'axios' - -type FlamingoTokenInfoPricesResponse = { - symbol: string - usd_price: number -}[] - -export class FlamingoExchange implements Exchange { - readonly network: Network - private axiosInstance: AxiosInstance - - constructor(network: Network) { - this.network = network - this.axiosInstance = axios.create({ baseURL: 'https://api.flamingo.finance' }) - } - - async getTokenPrices(currency: Currency): Promise { - if (this.network.type !== 'mainnet') throw new Error('Exchange is only available on mainnet') - - const { data: prices } = await this.axiosInstance.get('/token-info/prices') - - let currencyRatio: number = 1 - - if (currency !== 'USD') { - const { data } = await this.axiosInstance.get(`/fiat/exchange-rate?pair=USD_${currency}`) - currencyRatio = data - } - - return prices.map(price => ({ - amount: price.usd_price * currencyRatio, - symbol: price.symbol, - })) - } -} diff --git a/packages/bs-neo3/src/GhostMarketNDS.ts b/packages/bs-neo3/src/GhostMarketNDS.ts deleted file mode 100644 index 8a74413..0000000 --- a/packages/bs-neo3/src/GhostMarketNDS.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { BlockchainService, NFTResponse, NFTSResponse, NetworkType } from "@cityofzion/blockchain-service"; -import qs from "querystringify" -import axios from 'axios' - -type ImgMediaTypes = 'image/svg+xml' | 'image/png' | 'image/jpeg' - -interface GhostMarketNFT { - tokenId: string - contract: { - chain?: string - hash: string - symbol: string - } - creator: { - address?: string - offchainName?: string - } - apiUrl?: string - ownerships: { - owner: { - address?: string - } - }[] - collection: { - name?: string - logoUrl?: string - } - metadata: { - description: string - mediaType: ImgMediaTypes - mediaUri: string - mintDate: number - mintNumber: number - name: string - } -} - -interface GhostMarketAssets { - assets: GhostMarketNFT[] - total: number -} - -interface GhostMarketAsset { - assets: [ - { - collection: { - logo_url: string, - name: string - }, - metadata: { - mediaUri: string, - name: string, - mediaType: ImgMediaTypes - }, - contract: { - symbol: string, - hash: string - }, - tokenId: string - } - ] -} - -type ParamsTypeNFTS = { - owners: string[], - size: number, - page: number, - getTotal: boolean -} - -type ParamsTypeNFT = { - ["tokenIds[]"]: string[] - contract: string -} -/** - * NDS = NFT Data Service - */ -export class GhostMarketNDS { - private blockchain: BlockchainService - private baseUrl: Partial> = { mainnet: "https://api.ghostmarket.io/api/v2", testnet: "https://api-testnet.ghostmarket.io/api/v2" } - private ghostMarketNetworks: Partial> = { - mainnet: 'n3', - testnet: 'n3t' - } - path: string = 'assets' - private readonly url: string - constructor(blockchain: BlockchainService) { - this.blockchain = blockchain - this.url = `${this.baseUrl[this.blockchain.network.type]}/${this.path}` - } - - async getNFT(params: ParamsTypeNFT) { - const url = this.getUrlWithParams(params) - const response = (await axios.get(url)).data - return this.toNFTResponse(response, params["tokenIds[]"]) - } - - async getNFTS(params: ParamsTypeNFTS) { - const url = this.getUrlWithParams(params) - const response = (await axios.get(url)).data - return this.toNFTSResponse(response, params.size) - } - - private treatGhostMarketImage(srcImage?: string) { - if (!srcImage) { - return - } - - if (srcImage.startsWith('ipfs://')) { - const [, imageId] = srcImage.split('://') - - return `https://ghostmarket.mypinata.cloud/ipfs/${imageId}` - } - - return srcImage - } - - private getUrlWithParams(params: T) { - const parameters = qs.stringify( - { - chain: this.ghostMarketNetworks[this.blockchain.network.type], - ...params - } - ) - return `${this.url}?${parameters}` - } - private toNFTResponse(response: GhostMarketAsset, tokenID: string[]) { - const { assets } = response - - const nft = assets.find(asset => { - - return tokenID.includes(asset.tokenId) - }) - if (!nft) throw new Error("NFT not found"); - - const nftResponse: NFTResponse = { - collectionImage: this.treatGhostMarketImage(nft.collection?.logo_url), - id: nft.tokenId, - contractHash: nft.contract.hash, - symbol: nft.contract.symbol, - collectionName: nft.collection?.name, - image: this.treatGhostMarketImage(nft.metadata.mediaUri), - isSVG: nft.metadata.mediaType.includes('svg+xml'), - name: nft.metadata.name - } - - return nftResponse; - } - private toNFTSResponse(response: GhostMarketAssets, nftPageLimit: number) { - const { assets, total } = response - const totalPages = Math.ceil(total / nftPageLimit) - const nfts = assets.map(asset => ({ - collectionImage: this.treatGhostMarketImage(asset.collection.logoUrl), - collectionName: asset.collection.name, - image: this.treatGhostMarketImage(asset.metadata.mediaUri), - name: asset.metadata.name, - symbol: asset.contract.symbol, - id: asset.tokenId, - contractHash: asset.contract.hash, - isSVG: String(asset.metadata.mediaType).includes('svg+xml'), - })) - - const nftsResponse: NFTSResponse = { - totalPages: totalPages, - items: nfts - } - - return nftsResponse - } -} \ No newline at end of file diff --git a/packages/bs-neo3/src/GhostMarketNDSNeo3.ts b/packages/bs-neo3/src/GhostMarketNDSNeo3.ts new file mode 100644 index 0000000..d7db2a6 --- /dev/null +++ b/packages/bs-neo3/src/GhostMarketNDSNeo3.ts @@ -0,0 +1,117 @@ +import { + NftResponse, + NftsResponse, + NetworkType, + NftDataService, + GetNftParam, + GetNftsByAddressParams, +} from '@cityofzion/blockchain-service' +import qs from 'query-string' +import axios from 'axios' + +import { GHOSTMARKET_CHAIN_BY_NETWORK_TYPE, GHOSTMARKET_URL_BY_NETWORK_TYPE } from './constants' + +type GhostMarketNFT = { + tokenId: string + contract: { + chain?: string + hash: string + symbol: string + } + creator: { + address?: string + offchainName?: string + } + apiUrl?: string + ownerships: { + owner: { + address?: string + } + }[] + collection: { + name?: string + logoUrl?: string + } + metadata: { + description: string + mediaType: string + mediaUri: string + mintDate: number + mintNumber: number + name: string + } +} + +type GhostMarketAssets = { + assets: GhostMarketNFT[] + next: string +} + +export class GhostMarketNDSNeo3 implements NftDataService { + private networkType: NetworkType + + constructor(networkType: NetworkType) { + this.networkType = networkType + } + + async getNftsByAddress({ address, size = 18, cursor, page }: GetNftsByAddressParams): Promise { + const url = this.getUrlWithParams({ + size, + owners: [address], + cursor: cursor, + }) + const { data } = await axios.get(url) + const nfts = data.assets ?? [] + + return { nextCursor: data.next, items: nfts.map(this.parse.bind(this)) } + } + + async getNft({ contractHash, tokenId }: GetNftParam): Promise { + const url = this.getUrlWithParams({ + contract: contractHash, + tokenIds: [tokenId], + }) + const { data } = await axios.get(url) + return this.parse(data.assets[0]) + } + + private treatGhostMarketImage(srcImage?: string) { + if (!srcImage) { + return + } + + if (srcImage.startsWith('ipfs://')) { + const [, imageId] = srcImage.split('://') + + return `https://ghostmarket.mypinata.cloud/ipfs/${imageId}` + } + + return srcImage + } + + private getUrlWithParams(params: any) { + const parameters = qs.stringify( + { + chain: GHOSTMARKET_CHAIN_BY_NETWORK_TYPE[this.networkType], + ...params, + }, + { arrayFormat: 'bracket' } + ) + return `${GHOSTMARKET_URL_BY_NETWORK_TYPE[this.networkType]}/assets?${parameters}` + } + + private parse(data: GhostMarketNFT) { + const nftResponse: NftResponse = { + collectionImage: this.treatGhostMarketImage(data.collection?.logoUrl), + id: data.tokenId, + contractHash: data.contract.hash, + symbol: data.contract.symbol, + collectionName: data.collection?.name, + image: this.treatGhostMarketImage(data.metadata.mediaUri), + isSVG: String(data.metadata.mediaType).includes('svg+xml'), + name: data.metadata.name, + } + + return nftResponse + } +} diff --git a/packages/bs-neo3/src/RpcBDSNeo3.ts b/packages/bs-neo3/src/RpcBDSNeo3.ts index be52f1b..9c91299 100644 --- a/packages/bs-neo3/src/RpcBDSNeo3.ts +++ b/packages/bs-neo3/src/RpcBDSNeo3.ts @@ -12,9 +12,19 @@ import { } from '@cityofzion/blockchain-service' import { rpc, u } from '@cityofzion/neon-core' import { NeonInvoker } from '@cityofzion/neon-invoker' +import { TOKENS } from './constants' export class RPCBDSNeo3 implements BlockchainDataService, BDSClaimable { - constructor(readonly network: Network) {} + protected readonly tokenCache: Map = new Map() + protected readonly feeToken: Token + protected readonly claimToken: Token + readonly network: Network + + constructor(network: Network, feeToken: Token, claimToken: Token) { + this.network = network + this.feeToken = feeToken + this.claimToken = claimToken + } async getTransaction(hash: string): Promise { const rpcClient = new rpc.RPCClient(this.network.url) @@ -23,16 +33,18 @@ export class RPCBDSNeo3 implements BlockchainDataService, BDSClaimable { return { hash: response.hash, block: response.validuntilblock, - netfee: response.netfee, - sysfee: response.sysfee, - totfee: (Number(response.netfee) + Number(response.sysfee)).toString(), + fee: Number( + u.BigInteger.fromNumber(response.netfee ?? 0) + .add(u.BigInteger.fromNumber(response.sysfee ?? 0)) + .toDecimal(this.feeToken.decimals) + ), notifications: [], transfers: [], - time: response.blocktime.toString(), + time: response.blocktime, } } - async getHistoryTransactions(_address: string, _page: number): Promise { + async getTransactionsByAddress(_address: string, _page: number): Promise { throw new Error('Method not supported.') } @@ -56,6 +68,12 @@ export class RPCBDSNeo3 implements BlockchainDataService, BDSClaimable { } async getTokenInfo(tokenHash: string): Promise { + const localToken = TOKENS[this.network.type].find(token => token.hash === tokenHash) + if (localToken) return localToken + + if (this.tokenCache.has(tokenHash)) { + return this.tokenCache.get(tokenHash)! + } const rpcClient = new rpc.RPCClient(this.network.url) const contractState = await rpcClient.getContractState(tokenHash) @@ -76,13 +94,16 @@ export class RPCBDSNeo3 implements BlockchainDataService, BDSClaimable { const decimals = Number(response.stack[0].value) const symbol = u.base642utf8(response.stack[1].value as string) - - return { + const token = { name: contractState.manifest.name, symbol, hash: contractState.hash, decimals, } + + this.tokenCache.set(tokenHash, token) + + return token } async getBalance(address: string): Promise { @@ -90,34 +111,11 @@ export class RPCBDSNeo3 implements BlockchainDataService, BDSClaimable { const response = await rpcClient.getNep17Balances(address) const promises = response.balance.map>(async balance => { - const { - manifest: { name }, - } = await rpcClient.getContractState(balance.assethash) - - const invoker = await NeonInvoker.init({ - rpcAddress: this.network.url, - }) - - const response = await invoker.testInvoke({ - invocations: [ - { - scriptHash: balance.assethash, - operation: 'decimals', - args: [], - }, - { scriptHash: balance.assethash, operation: 'symbol', args: [] }, - ], - }) - - const decimals = Number(response.stack[0].value) - const symbol = u.base642utf8(response.stack[1].value as string) + const token = await this.getTokenInfo(balance.assethash) return { - amount: Number(u.BigInteger.fromNumber(balance.amount).toDecimal(decimals)), - hash: balance.assethash, - name, - symbol, - decimals, + amount: Number(u.BigInteger.fromNumber(balance.amount).toDecimal(token.decimals ?? 0)), + token, } }) const balances = await Promise.all(promises) @@ -128,6 +126,6 @@ export class RPCBDSNeo3 implements BlockchainDataService, BDSClaimable { async getUnclaimed(address: string): Promise { const rpcClient = new rpc.RPCClient(this.network.url) const response = await rpcClient.getUnclaimedGas(address) - return Number(response) + return Number(u.BigInteger.fromNumber(response).toDecimal(this.claimToken.decimals)) } } diff --git a/packages/bs-neo3/src/__tests__/BDSNeo3.spec.ts b/packages/bs-neo3/src/__tests__/BDSNeo3.spec.ts index 25e3381..9a7af10 100644 --- a/packages/bs-neo3/src/__tests__/BDSNeo3.spec.ts +++ b/packages/bs-neo3/src/__tests__/BDSNeo3.spec.ts @@ -1,9 +1,11 @@ import { BDSClaimable, BlockchainDataService } from '@cityofzion/blockchain-service' import { DoraBDSNeo3 } from '../DoraBDSNeo3' import { RPCBDSNeo3 } from '../RpcBDSNeo3' +import { TOKENS } from '../constants' -let doraBDSNeo3 = new DoraBDSNeo3({ type: 'testnet', url: 'https://testnet1.neo.coz.io:443' }) -let rpcBDSNeo3 = new RPCBDSNeo3({ type: 'testnet', url: 'https://testnet1.neo.coz.io:443' }) +const gasToken = TOKENS.testnet.find(t => t.symbol === 'GAS')! +let doraBDSNeo3 = new DoraBDSNeo3({ type: 'testnet', url: 'https://testnet1.neo.coz.io:443' }, gasToken, gasToken) +let rpcBDSNeo3 = new RPCBDSNeo3({ type: 'testnet', url: 'https://testnet1.neo.coz.io:443' }, gasToken, gasToken) describe('BDSNeo3', () => { it.each([doraBDSNeo3, rpcBDSNeo3])( @@ -18,23 +20,22 @@ describe('BDSNeo3', () => { hash, notifications: [], transfers: [], - time: expect.any(String), - netfee: expect.any(String), - sysfee: expect.any(String), - totfee: expect.any(String), + time: expect.any(Number), + fee: expect.any(Number), }) ) } ) it.each([doraBDSNeo3, rpcBDSNeo3])( - 'Should be able to get history transactions - %s', + 'Should be able to get transactions of address - %s', async (bdsNeo3: BlockchainDataService) => { const address = 'NNmTVFrSPhe7zjgN6iq9cLgXJwLZziUKV6' try { - const transaction = await bdsNeo3.getHistoryTransactions(address, 1) - expect(transaction).toEqual( - expect.arrayContaining( + const response = await bdsNeo3.getTransactionsByAddress(address, 1) + + response.transactions.forEach(transaction => { + expect(transaction).toEqual( expect.objectContaining({ block: expect.any(Number), hash: expect.any(String), @@ -51,19 +52,17 @@ describe('BDSNeo3', () => { ]), transfers: expect.arrayContaining([ expect.objectContaining({ - amount: expect.any(String), + amount: expect.any(Number), from: expect.any(String), to: expect.any(String), type: 'asset', }), ]), - time: expect.any(String), - netfee: expect.any(String), - sysfee: expect.any(String), - totfee: expect.any(String), + time: expect.any(Number), + fee: expect.any(Number), }) ) - ) + }) } catch {} } ) @@ -103,18 +102,17 @@ describe('BDSNeo3', () => { it.each([doraBDSNeo3, rpcBDSNeo3])('Should be able to get balance - %s', async (bdsNeo3: BlockchainDataService) => { const address = 'NNmTVFrSPhe7zjgN6iq9cLgXJwLZziUKV6' const balance = await bdsNeo3.getBalance(address) - - expect(balance).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - amount: expect.any(Number), + balance.forEach(balance => { + expect(balance).toEqual({ + amount: expect.any(Number), + token: { hash: expect.any(String), name: expect.any(String), symbol: expect.any(String), decimals: expect.any(Number), - }), - ]) - ) + }, + }) + }) }) it.each([doraBDSNeo3, rpcBDSNeo3])( @@ -122,6 +120,7 @@ describe('BDSNeo3', () => { async (bdsNeo3: BlockchainDataService & BDSClaimable) => { const address = 'NNmTVFrSPhe7zjgN6iq9cLgXJwLZziUKV6' const unclaimed = await bdsNeo3.getUnclaimed(address) + console.log(unclaimed) expect(unclaimed).toEqual(expect.any(Number)) } ) diff --git a/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts b/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts index 35e64ae..a5724bd 100644 --- a/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts +++ b/packages/bs-neo3/src/__tests__/BSNeo3.spec.ts @@ -21,24 +21,24 @@ describe('BSNeo3', () => { const validEncryptedKey = '6PYVPVe1fQznphjbUxXP9KZJqPMVnVwCx5s5pr5axRJ8uHkMtZg97eT5kL' const invalidEncryptedKey = 'invalid encrypted key' - expect(bsNeo3.validateEncryptedKey(validEncryptedKey)).toBeTruthy() - expect(bsNeo3.validateEncryptedKey(invalidEncryptedKey)).toBeFalsy() + expect(bsNeo3.validateEncrypted(validEncryptedKey)).toBeTruthy() + expect(bsNeo3.validateEncrypted(invalidEncryptedKey)).toBeFalsy() }) it('Should be able to validate a wif', () => { const validWif = 'L44B5gGEpqEDRS9vVPz7QT35jcBG2r3CZwSwQ4fCewXAhAhqGVpP' const invalidWif = 'invalid wif' - expect(bsNeo3.validateWif(validWif)).toBeTruthy() - expect(bsNeo3.validateWif(invalidWif)).toBeFalsy() + expect(bsNeo3.validateKey(validWif)).toBeTruthy() + expect(bsNeo3.validateKey(invalidWif)).toBeFalsy() }) it('Should be able to validate an domain', () => { const validDomain = 'test.neo' const invalidDomain = 'invalid domain' - expect(bsNeo3.validateNNSFormat(validDomain)).toBeTruthy() - expect(bsNeo3.validateNNSFormat(invalidDomain)).toBeFalsy() + expect(bsNeo3.validateNameServiceDomainFormat(validDomain)).toBeTruthy() + expect(bsNeo3.validateNameServiceDomainFormat(invalidDomain)).toBeFalsy() }) it('Should be able to generate a mnemonic', () => { @@ -53,14 +53,14 @@ describe('BSNeo3', () => { const account = bsNeo3.generateAccount(mnemonic, 0) expect(bsNeo3.validateAddress(account.address)).toBeTruthy() - expect(bsNeo3.validateWif(account.wif)).toBeTruthy() + expect(bsNeo3.validateKey(account.key)).toBeTruthy() }) it('Should be able to generate a account from wif', () => { const mnemonic = bsNeo3.generateMnemonic() const account = bsNeo3.generateAccount(mnemonic, 0) - const accountFromWif = bsNeo3.generateAccountFromWif(account.wif) + const accountFromWif = bsNeo3.generateAccountFromKey(account.key) expect(account).toEqual(accountFromWif) }) @@ -68,24 +68,22 @@ describe('BSNeo3', () => { const mnemonic = bsNeo3.generateMnemonic() const account = bsNeo3.generateAccount(mnemonic, 0) const password = 'TestPassword' - const encryptedKey = await wallet.encrypt(account.wif, password) - const decryptedAccount = await bsNeo3.decryptKey(encryptedKey, password) + const encryptedKey = await wallet.encrypt(account.key, password) + const decryptedAccount = await bsNeo3.decrypt(encryptedKey, password) expect(decryptedAccount).toEqual(account) }, 20000) it.skip('Should be able to calculate transfer fee', async () => { - const account = bsNeo3.generateAccountFromWif(process.env.TESTNET_PRIVATE_KEY as string) + const account = bsNeo3.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) const fee = await bsNeo3.calculateTransferFee({ senderAccount: account, - intents: [ - { - amount: 0.00000001, - receiverAddress: 'NPRMF5bmYuW23DeDJqsDJenhXkAPSJyuYe', - tokenHash: bsNeo3.feeToken.hash, - tokenDecimals: bsNeo3.feeToken.decimals, - }, - ], + intent: { + amount: 0.00000001, + receiverAddress: 'NPRMF5bmYuW23DeDJqsDJenhXkAPSJyuYe', + tokenHash: bsNeo3.feeToken.hash, + tokenDecimals: bsNeo3.feeToken.decimals, + }, }) expect(fee).toEqual({ @@ -96,35 +94,33 @@ describe('BSNeo3', () => { }) it.skip('Should be able to transfer', async () => { - const account = bsNeo3.generateAccountFromWif(process.env.TESTNET_PRIVATE_KEY as string) - const balance = await bsNeo3.dataService.getBalance(account.address) - const gasBalance = balance.find(b => b.symbol === bsNeo3.feeToken.symbol) + const account = bsNeo3.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) + const balance = await bsNeo3.blockchainDataService.getBalance(account.address) + const gasBalance = balance.find(b => b.token.symbol === bsNeo3.feeToken.symbol) expect(gasBalance?.amount).toBeGreaterThan(0.00000001) const transactionHash = await bsNeo3.transfer({ senderAccount: account, - intents: [ - { - amount: 0.00000001, - receiverAddress: 'NPRMF5bmYuW23DeDJqsDJenhXkAPSJyuYe', - tokenHash: bsNeo3.feeToken.hash, - tokenDecimals: bsNeo3.feeToken.decimals, - }, - ], + intent: { + amount: 0.00000001, + receiverAddress: 'NPRMF5bmYuW23DeDJqsDJenhXkAPSJyuYe', + tokenHash: bsNeo3.feeToken.hash, + tokenDecimals: bsNeo3.feeToken.decimals, + }, }) expect(transactionHash).toEqual(expect.any(String)) }) - it('Should be able to claim', async () => { - const account = bsNeo3.generateAccountFromWif(process.env.TESTNET_PRIVATE_KEY as string) + it.skip('Should be able to claim', async () => { + const account = bsNeo3.generateAccountFromKey(process.env.TESTNET_PRIVATE_KEY as string) const maxTries = 10 let tries = 0 while (tries < maxTries) { try { - const unclaimed = await bsNeo3.dataService.getUnclaimed(account.address) + const unclaimed = await bsNeo3.blockchainDataService.getUnclaimed(account.address) if (unclaimed <= 0) continue const transactionHash = await bsNeo3.claim(account) @@ -138,8 +134,8 @@ describe('BSNeo3', () => { } }, 60000) - it('Should be able to get an owner of a domain', async () => { - const owner = await bsNeo3.getOwnerOfNNS('neo.neo') + it('Should be able to resolve a name service domain', async () => { + const owner = await bsNeo3.resolveNameServiceDomain('neo.neo') expect(owner).toEqual('Nj39M97Rk2e23JiULBBMQmvpcnKaRHqxFf') }) }) diff --git a/packages/bs-neo3/src/__tests__/FlamingoEDSNeo3.spec.ts b/packages/bs-neo3/src/__tests__/FlamingoEDSNeo3.spec.ts new file mode 100644 index 0000000..d973640 --- /dev/null +++ b/packages/bs-neo3/src/__tests__/FlamingoEDSNeo3.spec.ts @@ -0,0 +1,45 @@ +import { FlamingoEDSNeo3 } from '../FlamingoEDSNeo3' + +let flamingoEDSNeo3: FlamingoEDSNeo3 + +describe('FlamingoEDSNeo3', () => { + beforeAll(() => { + flamingoEDSNeo3 = new FlamingoEDSNeo3('mainnet') + }) + it('Should return a list with prices of tokens using USD', async () => { + const tokenPriceList = await flamingoEDSNeo3.getTokenPrices('USD') + + tokenPriceList.forEach(tokenPrice => { + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) + }) + + it('Should return a list with prices of tokens using BRL', async () => { + const tokenPriceListInUSD = await flamingoEDSNeo3.getTokenPrices('USD') + const tokenPriceList = await flamingoEDSNeo3.getTokenPrices('BRL') + + tokenPriceList.forEach((tokenPrice, index) => { + expect(tokenPrice.amount).toBeGreaterThan(tokenPriceListInUSD[index].amount) + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) + }) + + it('Should return a list with prices of tokens using EUR', async () => { + const tokenPriceListInUSD = await flamingoEDSNeo3.getTokenPrices('USD') + const tokenPriceList = await flamingoEDSNeo3.getTokenPrices('EUR') + + tokenPriceList.forEach((tokenPrice, index) => { + expect(tokenPrice.amount).toBeLessThan(tokenPriceListInUSD[index].amount) + expect(tokenPrice).toEqual({ + amount: expect.any(Number), + symbol: expect.any(String), + }) + }) + }) +}) diff --git a/packages/bs-neo3/src/__tests__/GhostMaketNDS.spec.ts b/packages/bs-neo3/src/__tests__/GhostMaketNDS.spec.ts deleted file mode 100644 index 66b5ce2..0000000 --- a/packages/bs-neo3/src/__tests__/GhostMaketNDS.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { BSNeo3 } from '../BSNeo3' - -let bsNeo3: BSNeo3 -const address = "NNmTVFrSPhe7zjgN6iq9cLgXJwLZziUKV6" -describe('Ghostmarket', () => { - beforeAll(() => { - bsNeo3 = new BSNeo3('neo3', { type: 'mainnet', url: 'https://testnet1.neo.coz.io:443' }) - }) - it("Get NFT", async () => { - const nft = await bsNeo3.getNFT("SVBLTUYxMTY1", "0xaa4fb927b3fe004e689a278d188689c9f050a8b2") - expect(nft).toEqual( - expect.objectContaining( - { - id: expect.any(String), - contractHash: expect.any(String), - symbol: expect.any(String), - } - ) - ) - }) - it("Get NFTS", async () => { - const nfts = await bsNeo3.getNFTS(address) - expect(nfts.items).toEqual( - expect.arrayContaining([ - expect.objectContaining( - { - collectionImage: expect.any(String), - collectionName: expect.any(String), - image: expect.any(String), - name: expect.any(String), - symbol: expect.any(String), - id: expect.any(String), - contractHash: expect.any(String), - isSVG: expect.any(Boolean) - } - ) - ]) - ) - }) -}) diff --git a/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts b/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts new file mode 100644 index 0000000..1d58338 --- /dev/null +++ b/packages/bs-neo3/src/__tests__/GhostMarketNDSNeo3.spec.ts @@ -0,0 +1,43 @@ +import { GhostMarketNDSNeo3 } from '../GhostMarketNDSNeo3' + +let ghostMarketNDSNeo3: GhostMarketNDSNeo3 + +describe('GhostMarketNDSNeo3', () => { + beforeAll(() => { + ghostMarketNDSNeo3 = new GhostMarketNDSNeo3('mainnet') + }) + + it('Get NFT', async () => { + const nft = await ghostMarketNDSNeo3.getNft({ + contractHash: '0xaa4fb927b3fe004e689a278d188689c9f050a8b2', + tokenId: 'SVBLTUYxMTY1', + }) + expect(nft).toEqual( + expect.objectContaining({ + id: 'SVBLTUYxMTY1', + contractHash: '0xaa4fb927b3fe004e689a278d188689c9f050a8b2', + symbol: 'TTM', + collectionImage: expect.any(String), + collectionName: 'TOTHEMOON', + image: expect.any(String), + isSVG: expect.any(Boolean), + name: 'Pink Moon Fish', + }) + ) + }) + it('Get NFTS by address', async () => { + const nfts = await ghostMarketNDSNeo3.getNftsByAddress({ + address: 'NNmTVFrSPhe7zjgN6iq9cLgXJwLZziUKV6', + }) + expect(nfts.items.length).toBeGreaterThan(0) + nfts.items.forEach(nft => { + expect(nft).toEqual( + expect.objectContaining({ + symbol: expect.any(String), + id: expect.any(String), + contractHash: expect.any(String), + }) + ) + }) + }) +}) diff --git a/packages/bs-neo3/src/constants.ts b/packages/bs-neo3/src/constants.ts index cc785a0..233a2c8 100644 --- a/packages/bs-neo3/src/constants.ts +++ b/packages/bs-neo3/src/constants.ts @@ -15,3 +15,15 @@ export const DEFAULT_URL_BY_NETWORK_TYPE: Record = { testnet: 'https://testnet1.neo.coz.io:443', custom: 'http://127.0.0.1:50012', } + +export const GHOSTMARKET_URL_BY_NETWORK_TYPE: Partial> = { + mainnet: 'https://api.ghostmarket.io/api/v2', + testnet: 'https://api-testnet.ghostmarket.io/api/v2', +} + +export const GHOSTMARKET_CHAIN_BY_NETWORK_TYPE: Partial> = { + mainnet: 'n3', + testnet: 'n3t', +} + +export const DERIVATION_PATH = "m/44'/888'/0'/0/?" diff --git a/packages/bs-neo3/src/index.ts b/packages/bs-neo3/src/index.ts index 06f8778..bad314a 100644 --- a/packages/bs-neo3/src/index.ts +++ b/packages/bs-neo3/src/index.ts @@ -2,3 +2,5 @@ export * from './DoraBDSNeo3' export * from './RpcBDSNeo3' export * from './BSNeo3' export * from './constants' +export * from './FlamingoEDSNeo3' +export * from './GhostMarketNDSNeo3' diff --git a/packages/bs-neo3/tsconfig.build.json b/packages/bs-neo3/tsconfig.build.json new file mode 100644 index 0000000..4dc23fb --- /dev/null +++ b/packages/bs-neo3/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__"] +} diff --git a/packages/bs-neo3/tsconfig.json b/packages/bs-neo3/tsconfig.json index eb55a59..ca3bad7 100644 --- a/packages/bs-neo3/tsconfig.json +++ b/packages/bs-neo3/tsconfig.json @@ -2,10 +2,11 @@ "extends": "../../tsconfig.json", "compilerOptions": { "lib": ["ESNext"], - "outDir": "./dist" + "outDir": "./dist", + "types": ["jest"] }, "include": ["src"], - "exclude": ["node_modules", "src/__tests__"], + "exclude": ["node_modules"], "typedocOptions": { "entryPoints": ["./src/index.ts"], "out": "docs", diff --git a/packages/bs-react-native-decrypt/package.json b/packages/bs-react-native-decrypt/package.json index 7c0c1aa..876fa59 100644 --- a/packages/bs-react-native-decrypt/package.json +++ b/packages/bs-react-native-decrypt/package.json @@ -1,6 +1,6 @@ { "name": "@cityofzion/bs-react-native-decrypt", - "version": "0.6.0", + "version": "0.7.0", "description": "Native Decrypt for Neo Blockchain", "main": "dist/commonjs/index", "module": "dist/module/index",