diff --git a/core-graphql/.gitattributes b/core-graphql/.gitattributes new file mode 100644 index 0000000..63f6a5b --- /dev/null +++ b/core-graphql/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/README.md export-ignore diff --git a/core-graphql/README.md b/core-graphql/README.md new file mode 100644 index 0000000..bc17713 --- /dev/null +++ b/core-graphql/README.md @@ -0,0 +1,28 @@ +# ARK Core - GraphQL + +

+ +

+ +## Deprecated + +Note that this plugin is deprecated and should no longer be used. + +## Documentation + +You can find installation instructions and detailed instructions on how to use this package at the [dedicated documentation site](https://docs.ark.io/guidebook/core/plugins/deprecated/core-graphql.html). + +## Security + +If you discover a security vulnerability within this package, please send an e-mail to security@ark.io. All security vulnerabilities will be promptly addressed. + +## Credits + +- [Brian Faust](https://github.com/faustbrian) +- [Joshua Noack](https://github.com/supaiku0) +- [Lúcio Rubens](https://github.com/luciorubeens) +- [All Contributors](../../../../contributors) + +## License + +[MIT](LICENSE) © [ARK Ecosystem](https://ark.io) diff --git a/core-graphql/__tests__/__support__/setup.ts b/core-graphql/__tests__/__support__/setup.ts new file mode 100644 index 0000000..e2e1c50 --- /dev/null +++ b/core-graphql/__tests__/__support__/setup.ts @@ -0,0 +1,30 @@ +import { app } from "@arkecosystem/core-container"; +import { registerWithContainer, setUpContainer } from "../../../core-test-utils/src/helpers/container"; + +jest.setTimeout(60000); + +const options = { + enabled: true, + host: "0.0.0.0", + port: 4005, +}; + +export const setUp = async () => { + process.env.CORE_GRAPHQL_ENABLED = "true"; + + await setUpContainer({ + exclude: ["@arkecosystem/core-api", "@arkecosystem/core-forger"], + }); + + const { plugin } = require("../../src"); + await registerWithContainer(plugin, options); + + return app; +}; + +export const tearDown = async () => { + await app.tearDown(); + + const { plugin } = require("../../src"); + await plugin.deregister(app, options); +}; diff --git a/core-graphql/__tests__/__support__/utils.ts b/core-graphql/__tests__/__support__/utils.ts new file mode 100644 index 0000000..c968552 --- /dev/null +++ b/core-graphql/__tests__/__support__/utils.ts @@ -0,0 +1,16 @@ +import { app } from "@arkecosystem/core-container"; +import { ApiHelpers } from "../../../core-test-utils/src/helpers/api"; + +class Helpers { + public async request(query) { + const url = "http://localhost:4005/graphql"; + const server = app.resolvePlugin("graphql"); + + return ApiHelpers.request(server, "POST", url, {}, { query }); + } +} + +/** + * @type {Helpers} + */ +export const utils = new Helpers(); diff --git a/core-graphql/__tests__/api/address.test.ts b/core-graphql/__tests__/api/address.test.ts new file mode 100644 index 0000000..0a3335b --- /dev/null +++ b/core-graphql/__tests__/api/address.test.ts @@ -0,0 +1,40 @@ +import "@arkecosystem/core-test-utils"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(() => { + tearDown(); +}); + +describe("GraphQL API { address }", () => { + describe("GraphQL resolver for Address", () => { + it("should get wallter for a correctly formatted Address", async () => { + const query = '{ wallet(address: "APnhwwyTbMiykJwYbGhYjNgtHiVJDSEhSn") { producedBlocks } }'; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.wallet).toBeObject(); + + expect(data.wallet.producedBlocks).toBe(0); + }); + it("should return an error for an incorrectly formatted Address", async () => { + const query = '{ wallet(address: "bad address") { producedBlocks } }'; + const response = await utils.request(query); + + expect(response).not.toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeFalsy(); + expect(response.data.errors[0]).toBeObject(); + expect(response.data.errors[0].message).not.toBeNull(); + }); + }); +}); diff --git a/core-graphql/__tests__/api/block.test.ts b/core-graphql/__tests__/api/block.test.ts new file mode 100644 index 0000000..0ee445b --- /dev/null +++ b/core-graphql/__tests__/api/block.test.ts @@ -0,0 +1,29 @@ +import "@arkecosystem/core-test-utils"; +import genesisBlock from "../../../core-test-utils/src/config/testnet/genesisBlock.json"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(() => { + tearDown(); +}); + +describe("GraphQL API { block }", () => { + describe("GraphQL queries for Block", () => { + it("should get a block by its id", async () => { + const query = `{ block(id:"${genesisBlock.id}") { id } }`; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.block).toBeObject(); + expect(data.block.id).toBe(genesisBlock.id); + }); + }); +}); diff --git a/core-graphql/__tests__/api/blocks.test.ts b/core-graphql/__tests__/api/blocks.test.ts new file mode 100644 index 0000000..1403cdd --- /dev/null +++ b/core-graphql/__tests__/api/blocks.test.ts @@ -0,0 +1,54 @@ +import "@arkecosystem/core-test-utils"; +import genesisBlock from "../../../core-test-utils/src/config/testnet/genesisBlock.json"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(() => { + tearDown(); +}); + +describe("GraphQL API { blocks }", () => { + describe("GraphQL queries for Blocks - filter by generatorPublicKey", () => { + it("should get blocks by generatorPublicKey", async () => { + const query = `{ blocks(filter: { generatorPublicKey: "${genesisBlock.generatorPublicKey}" }) { id } }`; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.blocks).toEqual([{ id: genesisBlock.id }]); + }); + }); + + describe("GraphQL queries for Blocks - testing relationships", () => { + it("should verify that relationships are valid", async () => { + const query = "{ blocks(limit: 1) { generator { address } } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.blocks[0].generator.address).toEqual("AP6kAVdX1zQ3S8mfDnnHx9GaAohEqQUins"); + }); + }); + + describe("GraphQL queries for Blocks - testing api errors", () => { + it("should not be a successful query", async () => { + const query = "{ blocks(filter: { vers } ) { id } }"; + const response = await utils.request(query); + + expect(response).not.toBeSuccessfulResponse(); + + const error = response.data.errors; + expect(error).toBeArray(); + expect(response.status).toEqual(400); + }); + }); +}); diff --git a/core-graphql/__tests__/api/transaction.test.ts b/core-graphql/__tests__/api/transaction.test.ts new file mode 100644 index 0000000..68d3d62 --- /dev/null +++ b/core-graphql/__tests__/api/transaction.test.ts @@ -0,0 +1,29 @@ +import "@arkecosystem/core-test-utils"; +import genesisBlock from "../../../core-test-utils/src/config/testnet/genesisBlock.json"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(async () => { + await tearDown(); +}); + +describe("GraphQL API { transaction }", () => { + describe("GraphQL queries for Transaction", () => { + it("should get a transaction by its id", async () => { + const query = `{ transaction(id:"${genesisBlock.transactions[0].id}") { id } }`; + const response = await utils.request(query); + + await expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transaction).toBeObject(); + expect(data.transaction.id).toBe(genesisBlock.transactions[0].id); + }); + }); +}); diff --git a/core-graphql/__tests__/api/transactions.test.ts b/core-graphql/__tests__/api/transactions.test.ts new file mode 100644 index 0000000..6ed7807 --- /dev/null +++ b/core-graphql/__tests__/api/transactions.test.ts @@ -0,0 +1,174 @@ +import "@arkecosystem/core-test-utils"; +import genesisBlock from "../../../core-test-utils/src/config/testnet/genesisBlock.json"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(() => { + tearDown(); +}); + +describe("GraphQL API { transactions }", () => { + describe("GraphQL queries for Transactions - all", () => { + it("should get 100 transactions", async () => { + const query = "{ transactions { id } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transactions.length).toBe(100); + }); + }); + + describe("GraphQL queries for Transactions - orderBy", () => { + it("should get 100 transactionsin ascending order of their id", async () => { + const query = '{ transactions(orderBy: { field: "id", direction: ASC }) { id } }'; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transactions.length).toBe(100); + expect(data.transactions.sort((a, b) => (+a <= +b ? -1 : 0))).toEqual(data.transactions); + }); + }); + + describe("GraphQL queries for Transactions - filter by fee", () => { + it("should get all transactions with fee = 0", async () => { + const query = "{ transactions(filter: { fee: 0 }) { id } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transactions.length).toBe(100); // because of default limit = 100 + }); + + it("should get no transaction with fee = 987", async () => { + const query = "{ transactions(filter: { fee: 987 }) { id } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transactions.length).toBe(0); + }); + }); + + describe("GraphQL queries for Transactions - filter by blockId", () => { + it("should get transactions for given blockId", async () => { + const query = `{ transactions(filter: { blockId: "${genesisBlock.id}" }) { id } }`; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + + const genesisBlockTransactionIds = genesisBlock.transactions.map(transaction => transaction.id); + data.transactions.forEach(transaction => { + expect(genesisBlockTransactionIds).toContain(transaction.id); + }); + }); + }); + + describe("GraphQL queries for Transactions - filter by senderPublicKey", () => { + it("should get transactions for given senderPublicKey", async () => { + const query = `{ transactions(filter: { senderPublicKey: "${ + genesisBlock.transactions[0].senderPublicKey + }" }) { id } }`; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + // tslint:disable-next-line:max-line-length + expect(data.transactions.length).toEqual(51); // number of outgoing transactions for the 0th transaction's sender address + + const genesisBlockTransactionIds = genesisBlock.transactions.map(transaction => transaction.id); + + data.transactions.forEach(transaction => { + expect(genesisBlockTransactionIds).toContain(transaction.id); + }); + }); + }); + + describe("GraphQL queries for Transactions - filter by recipientId", () => { + it("should get transactions for given recipientId", async () => { + const query = '{ transactions(filter: { recipientId: "AHXtmB84sTZ9Zd35h9Y1vfFvPE2Xzqj8ri" }) { id } }'; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + + expect(data.transactions.length).toBe(2); + }); + }); + + describe("GraphQL queries for Transactions - filter by type", () => { + it("should get transactions for given type", async () => { + const query = "{ transactions(filter: { type: TRANSFER } ) { type } }"; + const response = await utils.request(query); + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + + data.transactions.forEach(tx => { + expect(tx.type).toBe(Number(0)); + }); + }); + }); + + describe("GraphQL queries for Transactions - using orderBy, limit", () => { + it("should get 5 transactions in order of ASCending address", async () => { + const query = '{ transactions(orderBy: { field: "id", direction: ASC }, limit: 5 ) { id } }'; + const response = await utils.request(query); + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transactions.length).toBe(5); + + expect(parseInt(data.transactions[0].id, 16)).toBeLessThan(parseInt(data.transactions[1].id, 16)); + }); + }); + + describe("GraphQL queries for Transactions - testing relationships", () => { + it("should verify that relationships are valid", async () => { + const query = "{ transactions(limit: 1) { recipient { address } } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.transactions[0].recipient.address).not.toBeNull(); + }); + }); + + describe("GraphQL queries for Transactions - testing api errors", () => { + it("should not be a successful query", async () => { + const query = "{ transaction(filter: { vers } ) { id } }"; + const response = await utils.request(query); + + expect(response).not.toBeSuccessfulResponse(); + + const error = response.data.errors; + expect(error).toBeArray(); + expect(response.status).toEqual(400); + }); + }); +}); diff --git a/core-graphql/__tests__/api/wallet.test.ts b/core-graphql/__tests__/api/wallet.test.ts new file mode 100644 index 0000000..f0a12e6 --- /dev/null +++ b/core-graphql/__tests__/api/wallet.test.ts @@ -0,0 +1,29 @@ +import "@arkecosystem/core-test-utils"; +import genesisBlock from "../../../core-test-utils/src/config/testnet/genesisBlock.json"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(() => { + tearDown(); +}); + +describe("GraphQL API { wallet }", () => { + describe("GraphQL queries for Wallet", () => { + it("should get a wallet by address", async () => { + const query = `{ wallet(address:"${genesisBlock.transactions[0].senderId}") { address } }`; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.wallet).toBeObject(); + expect(data.wallet.address).toBe(genesisBlock.transactions[0].senderId); + }); + }); +}); diff --git a/core-graphql/__tests__/api/wallets.test.ts b/core-graphql/__tests__/api/wallets.test.ts new file mode 100644 index 0000000..cb1dc40 --- /dev/null +++ b/core-graphql/__tests__/api/wallets.test.ts @@ -0,0 +1,93 @@ +/* tslint:disable:max-line-length */ + +import "@arkecosystem/core-test-utils"; + +import { setUp, tearDown } from "../__support__/setup"; +import { utils } from "../__support__/utils"; + +beforeAll(async () => { + await setUp(); +}); + +afterAll(() => { + tearDown(); +}); + +describe("GraphQL API { wallets }", () => { + describe("GraphQL queries for Wallets - get all", () => { + it("should get all wallets", async () => { + const query = "{ wallets { address } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.wallets.length).toBe(53); + // TODO why 53 ? From genesis block I can count 52, but there is an additional "AP6kAVdX1zQ3S8mfDnnHx9GaAohEqQUins" wallet. What did I miss ? + }); + }); + + describe("GraphQL queries for Wallets - filter by vote", () => { + it("should get all wallets with specific vote", async () => { + const query = + '{ wallets(filter: { vote: "036f612457adc81041662e664ca4ae64f844b412065f2b7d2f9f7d305e59c908cd" }) { address } }'; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.wallets.length).toBe(1); + }); + + it("should get no wallet with unknown vote", async () => { + const query = '{ wallets(filter: { vote: "unknownPublicKey" }) { address } }'; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.wallets.length).toBe(0); + }); + }); + + describe("GraphQL queries for Wallets - using orderBy, limit", () => { + it("should get 5 wallets in order of ASCending address", async () => { + const query = '{ wallets(orderBy: { field: "address", direction: ASC }, limit: 5 ) { address } }'; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + const data = response.data.data; + expect(data).toBeObject(); + expect(data.wallets.length).toBe(5); + expect(data.wallets.sort((a, b) => (+a <= +b ? -1 : 0))).toEqual(data.wallets); + }); + }); + + describe("GraphQL queries for Wallets - testing relationships", () => { + it("should verify that relationships are valid", async () => { + const query = "{ wallets(limit: 1) { transactions { id } } }"; + const response = await utils.request(query); + + expect(response).toBeSuccessfulResponse(); + + expect(response.data.errors[0]).toBeObject(); // relationships doesn't function well (unimplemented) + }); + }); + + describe("GraphQL queries for Wallets - testing api errors", () => { + it("should not be a successful query", async () => { + const query = "{ wallets(filter: { vers } ) { address } }"; + const response = await utils.request(query); + + expect(response).not.toBeSuccessfulResponse(); + + const error = response.data.errors; + expect(error).toBeArray(); + expect(response.status).toEqual(400); + }); + }); +}); diff --git a/core-graphql/package.json b/core-graphql/package.json new file mode 100644 index 0000000..d80f94d --- /dev/null +++ b/core-graphql/package.json @@ -0,0 +1,40 @@ +{ + "name": "@arkecosystem/core-graphql", + "version": "2.2.2", + "description": "GraphQL Integration for ARK Core", + "license": "MIT", + "contributors": [ + "Lúcio Rubens " + ], + "files": [ + "dist" + ], + "main": "dist/index.js", + "scripts": { + "build": "yarn clean && yarn compile", + "build:watch": "yarn clean && yarn compile -w", + "clean": "del dist", + "compile": "../../node_modules/typescript/bin/tsc", + "prepublishOnly": "yarn build", + "test": "cross-env CORE_ENV=test jest --runInBand --forceExit", + "test:coverage": "cross-env CORE_ENV=test jest --coverage --coveragePathIgnorePatterns='/(defaults.ts|index.ts)$' --runInBand --forceExit", + "test:debug": "cross-env CORE_ENV=test node --inspect-brk ../../node_modules/.bin/jest --runInBand", + "test:watch": "cross-env CORE_ENV=test jest --runInBand --watch", + "test:watch:all": "cross-env CORE_ENV=test jest --runInBand --watchAll" + }, + "dependencies": { + "@arkecosystem/core-container": "^2.2.2", + "@arkecosystem/core-http-utils": "^2.2.2", + "@arkecosystem/core-interfaces": "^2.2.2", + "@arkecosystem/crypto": "^2.2.2", + "apollo-server-hapi": "^2.4.0", + "graphql-tools-types": "^1.2.1" + }, + "devDependencies": {}, + "engines": { + "node": ">=10.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/core-graphql/src/apollo-server.ts b/core-graphql/src/apollo-server.ts new file mode 100644 index 0000000..0e76dfa --- /dev/null +++ b/core-graphql/src/apollo-server.ts @@ -0,0 +1,11 @@ +import { ApolloServer } from "apollo-server-hapi"; +import { typeDefs } from "./defs"; +import { resolvers } from "./resolvers"; + +/** + * Schema used by the Apollo GraphQL plugin for the hapi.js server. + */ +export const apolloServer = new ApolloServer({ + typeDefs, + resolvers, +}); diff --git a/core-graphql/src/defaults.ts b/core-graphql/src/defaults.ts new file mode 100644 index 0000000..a6ee749 --- /dev/null +++ b/core-graphql/src/defaults.ts @@ -0,0 +1,6 @@ +export const defaults = { + enabled: false, + host: process.env.CORE_GRAPHQL_HOST || "0.0.0.0", + port: process.env.CORE_GRAPHQL_PORT || 4005, + path: "/graphql", +}; diff --git a/core-graphql/src/defs/index.ts b/core-graphql/src/defs/index.ts new file mode 100644 index 0000000..de6af74 --- /dev/null +++ b/core-graphql/src/defs/index.ts @@ -0,0 +1,9 @@ +import { inputs } from "./inputs"; +import { root } from "./root"; +import { types } from "./types"; + +export const typeDefs = ` + ${inputs} + ${root} + ${types} +`; diff --git a/core-graphql/src/defs/inputs.ts b/core-graphql/src/defs/inputs.ts new file mode 100644 index 0000000..d6e0872 --- /dev/null +++ b/core-graphql/src/defs/inputs.ts @@ -0,0 +1,44 @@ +export const inputs = ` + scalar JSON + scalar Limit + scalar Offset + scalar Address + + enum OrderDirection { + ASC + DESC + } + + enum TransactionType { + TRANSFER, + SECOND_SIGNATURE, + DELEGATE, + VOTE, + MULTI_SIGNATURE, + IPFS, + TIMELOCK_TRANSFER, + MULTI_PAYMENT, + DELEGATE_RESIGNATION + } + + input TransactionFilter { + fee: Float + blockId: String + senderPublicKey: String + recipientId: String + type: TransactionType + } + + input BlockFilter { + generatorPublicKey: String + } + + input WalletFilter { + vote: String + } + + input OrderByInput { + field: String + direction: OrderDirection + } +`; diff --git a/core-graphql/src/defs/root.ts b/core-graphql/src/defs/root.ts new file mode 100644 index 0000000..25b2243 --- /dev/null +++ b/core-graphql/src/defs/root.ts @@ -0,0 +1,14 @@ +export const root = ` + type Query { + block(id: String): Block + blocks(limit: Limit, offset: Offset, orderBy: OrderByInput, filter: BlockFilter): [Block] + transaction(id: String): Transaction + transactions(limit: Limit, orderBy: OrderByInput, filter: TransactionFilter): [Transaction] + wallet(address: Address, publicKey: String, username: String): Wallet + wallets(limit: Limit, orderBy: OrderByInput, filter: WalletFilter): [Wallet] + } + + schema { + query: Query + } +`; diff --git a/core-graphql/src/defs/types.ts b/core-graphql/src/defs/types.ts new file mode 100644 index 0000000..a9197aa --- /dev/null +++ b/core-graphql/src/defs/types.ts @@ -0,0 +1,48 @@ +export const types = ` + type Block { + id: String + version: Int! + timestamp: Int! + previousBlock: String + height: Int! + numberOfTransactions: Int! + totalAmount: Float + totalFee: Float + reward: Float + payloadLength: Int! + payloadHash: String + generatorPublicKey: String + blockSignature: String + transactions(limit: Limit, offset: Offset, orderBy: OrderByInput, filter: TransactionFilter): [Transaction] + generator: Wallet + } + + type Transaction { + id: String + version: Int! + timestamp: Int! + senderPublicKey: String + recipientId: Address + type: Int! + vendorField: String + amount: Float + fee: Float + signature: String + block: Block + recipient: Wallet + sender: Wallet + } + + type Wallet { + address: Address + publicKey: String + secondPublicKey: String + vote: String + username: String + balance: Float + voteBalance: Float + producedBlocks: Float + transactions(limit: Limit, offset: Offset, orderBy: OrderByInput): [Transaction] + blocks(limit: Limit, offset: Offset, orderBy: OrderByInput): [Block] + } +`; diff --git a/core-graphql/src/helpers/format-orderBy.ts b/core-graphql/src/helpers/format-orderBy.ts new file mode 100644 index 0000000..61b3255 --- /dev/null +++ b/core-graphql/src/helpers/format-orderBy.ts @@ -0,0 +1,15 @@ +/** + * Logic used by our orderBy input + * @param {Object} parameter + * @param {String} defaultValue + * @return {String} + */ +export function formatOrderBy(parameter, defaultValue) { + let order; + + if (parameter) { + order = `${parameter.field}:${parameter.direction.toLowerCase()}`; + } + + return order || defaultValue; +} diff --git a/core-graphql/src/helpers/index.ts b/core-graphql/src/helpers/index.ts new file mode 100644 index 0000000..d667ee2 --- /dev/null +++ b/core-graphql/src/helpers/index.ts @@ -0,0 +1,4 @@ +import { formatOrderBy } from "./format-orderBy"; +import { unserializeTransactions } from "./unserialize-transactions"; + +export { formatOrderBy, unserializeTransactions }; diff --git a/core-graphql/src/helpers/unserialize-transactions.ts b/core-graphql/src/helpers/unserialize-transactions.ts new file mode 100644 index 0000000..36ad005 --- /dev/null +++ b/core-graphql/src/helpers/unserialize-transactions.ts @@ -0,0 +1,19 @@ +import { Transaction } from "@arkecosystem/crypto"; + +/** + * Deserialize multiple transactions + */ +export async function unserializeTransactions(data) { + const deserialize = buffer => { + return Transaction.fromBytes(buffer); + }; + + if (Array.isArray(data)) { + return data.reduce((total, value, key) => { + total.push(deserialize(value.serialized)); + + return total; + }, []); + } + return deserialize(data); +} diff --git a/core-graphql/src/index.ts b/core-graphql/src/index.ts new file mode 100644 index 0000000..8989a6c --- /dev/null +++ b/core-graphql/src/index.ts @@ -0,0 +1,28 @@ +import { Container, Logger } from "@arkecosystem/core-interfaces"; +import { defaults } from "./defaults"; +import { startServer } from "./server"; + +/** + * The struct used by the plugin manager. + * @type {Object} + */ +export const plugin: Container.PluginDescriptor = { + pkg: require("../package.json"), + defaults, + alias: "graphql", + async register(container: Container.IContainer, options) { + if (!options.enabled) { + container.resolvePlugin("logger").info("GraphQL API is disabled"); + return; + } + + return startServer(options); + }, + async deregister(container: Container.IContainer, options) { + if (options.enabled) { + container.resolvePlugin("logger").info("Stopping GraphQL API"); + + return container.resolvePlugin("graphql").stop(); + } + }, +}; diff --git a/core-graphql/src/repositories/blocks.ts b/core-graphql/src/repositories/blocks.ts new file mode 100644 index 0000000..f1e12c6 --- /dev/null +++ b/core-graphql/src/repositories/blocks.ts @@ -0,0 +1,133 @@ +import { Repository } from "./repository"; +import { buildFilterQuery } from "./utils/filter-query"; + +class BlocksRepository extends Repository { + /** + * Get all blocks for the given parameters. + * @param {Object} parameters + * @return {Object} + */ + public async findAll(parameters: any = {}) { + const selectQuery = this.query.select().from(this.query); + const countQuery = this._makeEstimateQuery(); + + const applyConditions = queries => { + const conditions = Object.entries(this._formatConditions(parameters)); + + if (conditions.length) { + const first = conditions.shift(); + + for (const item of queries) { + item.where(this.query[first[0]].equals(first[1])); + + for (const condition of conditions) { + item.and(this.query[condition[0]].equals(condition[1])); + } + } + } + }; + + applyConditions([selectQuery, countQuery]); + + return this._findManyWithCount(selectQuery, countQuery, { + limit: parameters.limit || 100, + offset: parameters.offset || 0, + orderBy: this.__orderBy(parameters), + }); + } + + /** + * Get all blocks for the given generator. + * @param {String} generatorPublicKey + * @param {Object} paginator + * @return {Object} + */ + public async findAllByGenerator(generatorPublicKey, paginator) { + return this.findAll({ ...{ generatorPublicKey }, ...paginator }); + } + + /** + * Get a block. + * @param {Number} id + * @return {Object} + */ + public async findById(id) { + const query = this.query + .select() + .from(this.query) + .where(this.query.id.equals(id)); + + return this._find(query); + } + + /** + * Get the last block for the given generator. + * TODO is this right? + * @param {String} generatorPublicKey + * @return {Object} + */ + public async findLastByPublicKey(generatorPublicKey) { + const query = this.query + .select(this.query.id, this.query.timestamp) + .from(this.query) + .where(this.query.generator_public_key.equals(generatorPublicKey)) + .order(this.query.height.desc); + + return this._find(query); + } + + /** + * Search all blocks. + * @param {Object} parameters + * @return {Object} + */ + public async search(parameters) { + const selectQuery = this.query.select().from(this.query); + const countQuery = this._makeEstimateQuery(); + + const applyConditions = queries => { + const conditions = buildFilterQuery(this._formatConditions(parameters), { + exact: ["id", "version", "previous_block", "payload_hash", "generator_public_key", "block_signature"], + between: [ + "timestamp", + "height", + "number_of_transactions", + "total_amount", + "total_fee", + "reward", + "payload_length", + ], + }); + + if (conditions.length) { + const first = conditions.shift(); + + for (const item of queries) { + item.where(this.query[first.column][first.method](first.value)); + + for (const condition of conditions) { + item.and(this.query[condition.column][condition.method](condition.value)); + } + } + } + }; + + applyConditions([selectQuery, countQuery]); + + return this._findManyWithCount(selectQuery, countQuery, { + limit: parameters.limit, + offset: parameters.offset, + orderBy: this.__orderBy(parameters), + }); + } + + public getModel() { + return (this.databaseService.connection as any).models.block; + } + + public __orderBy(parameters) { + return parameters.orderBy ? parameters.orderBy.split(":").map(p => p.toLowerCase()) : ["height", "desc"]; + } +} + +export const blockRepository = new BlocksRepository(); diff --git a/core-graphql/src/repositories/index.ts b/core-graphql/src/repositories/index.ts new file mode 100644 index 0000000..d747df1 --- /dev/null +++ b/core-graphql/src/repositories/index.ts @@ -0,0 +1,4 @@ +import { blockRepository } from "./blocks"; +import { transactionRepository } from "./transactions"; + +export { blockRepository, transactionRepository }; diff --git a/core-graphql/src/repositories/repository.ts b/core-graphql/src/repositories/repository.ts new file mode 100644 index 0000000..2f5b554 --- /dev/null +++ b/core-graphql/src/repositories/repository.ts @@ -0,0 +1,65 @@ +import { app } from "@arkecosystem/core-container"; +import { Database, TransactionPool } from "@arkecosystem/core-interfaces"; + +export abstract class Repository { + protected readonly databaseService = app.resolvePlugin("database"); + protected readonly transactionPool = app.resolvePlugin("transaction-pool"); + protected readonly cache = this.databaseService.cache; + protected readonly model = this.getModel(); + protected readonly query = this.model.query(); + + public abstract getModel(): Database.IModel; + + public async _find(query) { + return (this.databaseService.connection as any).query.oneOrNone(query.toQuery()); + } + + public async _findMany(query) { + return (this.databaseService.connection as any).query.manyOrNone(query.toQuery()); + } + + public async _findManyWithCount(selectQuery, countQuery, { limit, offset, orderBy }) { + const { count } = await this._find(countQuery); + + selectQuery + .order(this.query[orderBy[0]][orderBy[1]]) + .offset(offset) + .limit(limit); + + limit = 100; + offset = 0; + const rows = await this._findMany(selectQuery); + return { + rows, + count: +count, + }; + } + + public _makeCountQuery() { + return this.query.select("count(*) AS count").from(this.query); + } + + public _makeEstimateQuery() { + return this.query.select("count(*) AS count").from(`${this.model.getTable()} TABLESAMPLE SYSTEM (100)`); + } + + public _formatConditions(parameters) { + const columns = this.model.getColumnSet().columns.map(column => ({ + name: column.name, + prop: column.prop || column.name, + })); + + const columnNames = columns.map(column => column.name); + const columnProps = columns.map(column => column.prop); + + const filter = args => args.filter(arg => columnNames.includes(arg) || columnProps.includes(arg)); + + return filter(Object.keys(parameters)).reduce((items, item) => { + const columnName = columns.find(column => column.prop === item).name; + + items[columnName] = parameters[item]; + + return items; + }, {}); + } +} diff --git a/core-graphql/src/repositories/transactions.ts b/core-graphql/src/repositories/transactions.ts new file mode 100644 index 0000000..c525317 --- /dev/null +++ b/core-graphql/src/repositories/transactions.ts @@ -0,0 +1,438 @@ +import { constants, slots } from "@arkecosystem/crypto"; +import { dato } from "@faustbrian/dato"; +import { Repository } from "./repository"; +import { buildFilterQuery } from "./utils/filter-query"; + +const { TransactionType } = constants; + +class TransactionsRepository extends Repository { + /** + * Get all transactions. + * @param {Object} params + * @return {Object} + */ + public async findAll(parameters: any = {}) { + const selectQuery = this.query.select().from(this.query); + const countQuery = this._makeEstimateQuery(); + + if (parameters.senderId) { + const senderPublicKey = this.__publicKeyFromSenderId(parameters.senderId); + + if (!senderPublicKey) { + return { rows: [], count: 0 }; + } + + parameters.senderPublicKey = senderPublicKey; + } + + if (parameters.type) { + parameters.type = TransactionType[parameters.type]; + } + + const applyConditions = queries => { + const conditions = Object.entries(this._formatConditions(parameters)); + + if (conditions.length) { + const first = conditions.shift(); + + for (const item of queries) { + item.where(this.query[first[0]].equals(first[1])); + + for (const condition of conditions) { + item.and(this.query[condition[0]].equals(condition[1])); + } + } + } + }; + + applyConditions([selectQuery, countQuery]); + + const results = await this._findManyWithCount(selectQuery, countQuery, { + limit: parameters.limit || 100, + offset: parameters.offset || 0, + orderBy: this.__orderBy(parameters), + }); + + results.rows = await this.__mapBlocksToTransactions(results.rows); + return results; + } + + /** + * Get all transactions (LEGACY, for V1 only). + * @param {Object} params + * @return {Object} + */ + public async findAllLegacy(parameters: any = {}) { + const selectQuery = this.query.select(this.query.block_id, this.query.serialized).from(this.query); + const countQuery = this._makeEstimateQuery(); + + if (parameters.senderId) { + parameters.senderPublicKey = this.__publicKeyFromSenderId(parameters.senderId); + } + + const applyConditions = queries => { + const conditions = Object.entries(this._formatConditions(parameters)); + + if (conditions.length) { + const first = conditions.shift(); + + for (const item of queries) { + item.where(this.query[first[0]].equals(first[1])); + + for (const [key, value] of conditions) { + item.or(this.query[key].equals(value)); + } + } + } + }; + + applyConditions([selectQuery, countQuery]); + + const results = await this._findManyWithCount(selectQuery, countQuery, { + limit: parameters.limit, + offset: parameters.offset, + orderBy: this.__orderBy(parameters), + }); + + results.rows = await this.__mapBlocksToTransactions(results.rows); + + return results; + } + + /** + * Get all transactions for the given Wallet object. + * @param {Wallet} wallet + * @param {Object} parameters + * @return {Object} + */ + public async findAllByWallet(wallet, parameters: any = {}) { + const selectQuery = this.query.select(this.query.block_id, this.query.serialized).from(this.query); + const countQuery = this._makeEstimateQuery(); + + const applyConditions = queries => { + for (const item of queries) { + item.where(this.query.sender_public_key.equals(wallet.publicKey)).or( + this.query.recipient_id.equals(wallet.address), + ); + } + }; + + applyConditions([selectQuery, countQuery]); + + const results = await this._findManyWithCount(selectQuery, countQuery, { + limit: parameters.limit, + offset: parameters.offset, + orderBy: this.__orderBy(parameters), + }); + + results.rows = await this.__mapBlocksToTransactions(results.rows); + + return results; + } + + /** + * Get all transactions for the given sender public key. + * @param {String} senderPublicKey + * @param {Object} parameters + * @return {Object} + */ + public async findAllBySender(senderPublicKey, parameters = {}) { + return this.findAll({ ...{ senderPublicKey }, ...parameters }); + } + + /** + * Get all transactions for the given recipient address. + * @param {String} recipientId + * @param {Object} parameters + * @return {Object} + */ + public async findAllByRecipient(recipientId, parameters = {}) { + return this.findAll({ ...{ recipientId }, ...parameters }); + } + + /** + * Get all vote transactions for the given sender public key. + * TODO rename to findAllVotesBySender or not? + * @param {String} senderPublicKey + * @param {Object} parameters + * @return {Object} + */ + public async allVotesBySender(senderPublicKey, parameters = {}) { + return this.findAll({ + ...{ senderPublicKey, type: TransactionType.Vote }, + ...parameters, + }); + } + + /** + * Get all transactions for the given block. + * @param {Number} blockId + * @param {Object} parameters + * @return {Object} + */ + public async findAllByBlock(blockId, parameters = {}) { + return this.findAll({ ...{ blockId }, ...parameters }); + } + + /** + * Get all transactions for the given type. + * @param {Number} type + * @param {Object} parameters + * @return {Object} + */ + public async findAllByType(type, parameters = {}) { + return this.findAll({ ...{ type }, ...parameters }); + } + + /** + * Get a transaction. + * @param {Number} id + * @return {Object} + */ + public async findById(id) { + const query = this.query + .select(this.query.block_id, this.query.serialized) + .from(this.query) + .where(this.query.id.equals(id)); + + const transaction = await this._find(query); + + return this.__mapBlocksToTransactions(transaction); + } + + /** + * Get a transactions for the given type and id. + * @param {Number} type + * @param {Number} id + * @return {Object} + */ + public async findByTypeAndId(type, id) { + const query = this.query + .select(this.query.block_id, this.query.serialized) + .from(this.query) + .where(this.query.id.equals(id).and(this.query.type.equals(type))); + + const transaction = await this._find(query); + + return this.__mapBlocksToTransactions(transaction); + } + + /** + * Get transactions for the given ids. + * @param {Array} ids + * @return {Object} + */ + public async findByIds(ids) { + const query = this.query + .select(this.query.block_id, this.query.serialized) + .from(this.query) + .where(this.query.id.in(ids)); + + return this._findMany(query); + } + + /** + * Get all transactions that have a vendor field. + * @return {Object} + */ + public async findWithVendorField() { + const query = this.query + .select(this.query.block_id, this.query.serialized) + .from(this.query) + .where(this.query.vendor_field_hex.isNotNull()); + + const rows = await this._findMany(query); + + return this.__mapBlocksToTransactions(rows); + } + + /** + * Calculates min, max and average fee statistics based on transactions table + * @return {Object} + */ + public async getFeeStatistics() { + const query = this.query + .select( + this.query.type, + this.query.fee.min("minFee"), + this.query.fee.max("maxFee"), + this.query.fee.avg("avgFee"), + this.query.timestamp.max("timestamp"), + ) + .from(this.query) + .where( + this.query.timestamp.gte( + slots.getTime( + dato() + .subDays(30) + .toMilliseconds(), + ), + ), + ) + .and(this.query.fee.gte(this.transactionPool.options.dynamicFees.minFeeBroadcast)) + .group(this.query.type) + .order('"timestamp" DESC'); + + return this._findMany(query); + } + + /** + * Search all transactions. + * + * @param {Object} params + * @return {Object} + */ + public async search(parameters) { + const selectQuery = this.query.select().from(this.query); + const countQuery = this._makeEstimateQuery(); + + if (parameters.senderId) { + const senderPublicKey = this.__publicKeyFromSenderId(parameters.senderId); + + if (senderPublicKey) { + parameters.senderPublicKey = senderPublicKey; + } + } + + const applyConditions = queries => { + const conditions = buildFilterQuery(this._formatConditions(parameters), { + exact: ["id", "block_id", "type", "version", "sender_public_key", "recipient_id"], + between: ["timestamp", "amount", "fee"], + wildcard: ["vendor_field_hex"], + }); + + if (conditions.length) { + const first = conditions.shift(); + + for (const item of queries) { + item.where(this.query[first.column][first.method](first.value)); + + for (const condition of conditions) { + item.and(this.query[condition.column][condition.method](condition.value)); + } + } + } + }; + + applyConditions([selectQuery, countQuery]); + + const results = await this._findManyWithCount(selectQuery, countQuery, { + limit: parameters.limit, + offset: parameters.offset, + orderBy: this.__orderBy(parameters), + }); + + results.rows = await this.__mapBlocksToTransactions(results.rows); + + return results; + } + + public getModel() { + return (this.databaseService.connection as any).models.transaction; + } + + /** + * [__mapBlocksToTransactions description] + * @param {Array|Object} data + * @return {Object} + */ + public async __mapBlocksToTransactions(data) { + const blockQuery = (this.databaseService.connection as any).models.block.query(); + + // Array... + if (Array.isArray(data)) { + // 1. get heights from cache + const missingFromCache = []; + + for (let i = 0; i < data.length; i++) { + const cachedBlock = this.__getBlockCache(data[i].blockId); + + if (cachedBlock) { + data[i].block = cachedBlock; + } else { + missingFromCache.push({ + index: i, + blockId: data[i].blockId, + }); + } + } + + // 2. get missing heights from database + if (missingFromCache.length) { + const query = blockQuery + .select(blockQuery.id, blockQuery.height) + .from(blockQuery) + .where(blockQuery.id.in(missingFromCache.map(d => d.blockId))) + .group(blockQuery.id); + + const blocks = await this._findMany(query); + + for (const missing of missingFromCache) { + const block = blocks.find(item => item.id === missing.blockId); + if (block) { + data[missing.index].block = block; + this.__setBlockCache(block); + } + } + } + + return data; + } + + // Object... + if (data) { + const cachedBlock = this.__getBlockCache(data.blockId); + + if (cachedBlock) { + data.block = cachedBlock; + } else { + const query = blockQuery + .select(blockQuery.id, blockQuery.height) + .from(blockQuery) + .where(blockQuery.id.equals(data.blockId)); + + data.block = await this._find(query); + + this.__setBlockCache(data.block); + } + } + + return data; + } + + /** + * Tries to retrieve the height of the block from the cache + * @param {String} blockId + * @return {Object|null} + */ + public __getBlockCache(blockId) { + const height = this.cache.get(`heights:${blockId}`); + + return height ? { height, id: blockId } : null; + } + + /** + * Stores the height of the block on the cache + * @param {Object} block + * @param {String} block.id + * @param {Number} block.height + */ + public __setBlockCache({ id, height }) { + this.cache.set(`heights:${id}`, height); + } + + /** + * Retrieves the publicKey of the address from the WalletManager in-memory data + * @param {String} senderId + * @return {String} + */ + public __publicKeyFromSenderId(senderId) { + return this.databaseService.walletManager.findByAddress(senderId).publicKey; + } + + public __orderBy(parameters) { + return parameters.orderBy ? parameters.orderBy.split(":").map(p => p.toLowerCase()) : ["timestamp", "desc"]; + } +} + +export const transactionRepository = new TransactionsRepository(); diff --git a/core-graphql/src/repositories/utils/filter-query.ts b/core-graphql/src/repositories/utils/filter-query.ts new file mode 100644 index 0000000..3c41c8c --- /dev/null +++ b/core-graphql/src/repositories/utils/filter-query.ts @@ -0,0 +1,71 @@ +/** + * Create a "where" object for a sql query. + * @param {Object} parameters + * @param {Object} filters + * @return {Object} + */ +export function buildFilterQuery(parameters, filters) { + const where = []; + + if (filters.exact) { + for (const elem of filters.exact) { + if (typeof parameters[elem] !== "undefined") { + where.push({ + column: elem, + method: "equals", + value: parameters[elem], + }); + } + } + } + + if (filters.between) { + for (const elem of filters.between) { + if (!parameters[elem]) { + continue; + } + + if (!parameters[elem].from && !parameters[elem].to) { + where.push({ + column: elem, + method: "equals", + value: parameters[elem], + }); + } + + if (parameters[elem].from || parameters[elem].to) { + where[elem] = {}; + + if (parameters[elem].from) { + where.push({ + column: elem, + method: "gte", + value: parameters[elem].from, + }); + } + + if (parameters[elem].to) { + where.push({ + column: elem, + method: "lte", + value: parameters[elem].to, + }); + } + } + } + } + + if (filters.wildcard) { + for (const elem of filters.wildcard) { + if (parameters[elem]) { + where.push({ + column: elem, + method: "like", + value: `%${parameters[elem]}%`, + }); + } + } + } + + return where; +} diff --git a/core-graphql/src/resolvers/index.ts b/core-graphql/src/resolvers/index.ts new file mode 100644 index 0000000..91626b4 --- /dev/null +++ b/core-graphql/src/resolvers/index.ts @@ -0,0 +1,31 @@ +import GraphQLTypes from "graphql-tools-types"; +import * as queries from "./queries"; +import { Block } from "./relationship/block"; +import { Transaction } from "./relationship/transaction"; +import { Wallet } from "./relationship/wallet"; + +/** + * Resolvers used by the executed schema when encountering a + * scalar or type. + * + * All of our scalars are based on graphql-tools-types which helps us with + * query standardization. + * + * We introduce relationships and queries for our own types, + * these hold the data processing responsibilities of the complete + * GraphQL query flow. + */ + +export const resolvers = { + JSON: GraphQLTypes.JSON({ name: "Json" }), + Limit: GraphQLTypes.Int({ name: "Limit", min: 1, max: 100 }), + Offset: GraphQLTypes.Int({ name: "Offset", min: 0 }), + Address: GraphQLTypes.String({ + name: "Address", + regex: /^[AaDd]{1}[0-9a-zA-Z]{33}/, + }), + Query: queries, + Block, + Transaction, + Wallet, +}; diff --git a/core-graphql/src/resolvers/queries/block/block.ts b/core-graphql/src/resolvers/queries/block/block.ts new file mode 100644 index 0000000..d6e1fcb --- /dev/null +++ b/core-graphql/src/resolvers/queries/block/block.ts @@ -0,0 +1,10 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; + +/** + * Get a single block from the database + * @return {Block} + */ +export async function block(_, { id }) { + return app.resolvePlugin("database").connection.blocksRepository.findById(id); +} diff --git a/core-graphql/src/resolvers/queries/block/blocks.ts b/core-graphql/src/resolvers/queries/block/blocks.ts new file mode 100644 index 0000000..1b4db4a --- /dev/null +++ b/core-graphql/src/resolvers/queries/block/blocks.ts @@ -0,0 +1,16 @@ +import { formatOrderBy } from "../../../helpers"; +import { blockRepository } from "../../../repositories"; + +/** + * Get multiple blocks from the database + * @return {Block[]} + */ +export async function blocks(_, args: any) { + const { orderBy, filter } = args; + + const order = formatOrderBy(orderBy, "height:desc"); + + const result = await blockRepository.findAll({ ...filter, orderBy: order }); + + return result ? result.rows : []; +} diff --git a/core-graphql/src/resolvers/queries/index.ts b/core-graphql/src/resolvers/queries/index.ts new file mode 100644 index 0000000..e89c5b4 --- /dev/null +++ b/core-graphql/src/resolvers/queries/index.ts @@ -0,0 +1,8 @@ +import { block } from "./block/block"; +import { blocks } from "./block/blocks"; +import { transaction } from "./transaction/transaction"; +import { transactions } from "./transaction/transactions"; +import { wallet } from "./wallet/wallet"; +import { wallets } from "./wallet/wallets"; + +export { block, blocks, transaction, transactions, wallet, wallets }; diff --git a/core-graphql/src/resolvers/queries/transaction/transaction.ts b/core-graphql/src/resolvers/queries/transaction/transaction.ts new file mode 100644 index 0000000..e2c3b8d --- /dev/null +++ b/core-graphql/src/resolvers/queries/transaction/transaction.ts @@ -0,0 +1,10 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; + +/** + * Get a single transaction from the database + * @return {Transaction} + */ +export async function transaction(_, { id }) { + return app.resolvePlugin("database").connection.transactionsRepository.findById(id); +} diff --git a/core-graphql/src/resolvers/queries/transaction/transactions.ts b/core-graphql/src/resolvers/queries/transaction/transactions.ts new file mode 100644 index 0000000..9c6b993 --- /dev/null +++ b/core-graphql/src/resolvers/queries/transaction/transactions.ts @@ -0,0 +1,13 @@ +import { formatOrderBy } from "../../../helpers"; +import { transactionRepository } from "../../../repositories"; + +/** + * Get multiple transactions from the database + * @return {Transaction[]} + */ +export async function transactions(_, args: any) { + const { orderBy, filter, limit } = args; + const order = formatOrderBy(orderBy, "timestamp:desc"); + const result = await transactionRepository.findAll({ ...filter, orderBy: order, limit }); + return result ? result.rows : []; +} diff --git a/core-graphql/src/resolvers/queries/wallet/wallet.ts b/core-graphql/src/resolvers/queries/wallet/wallet.ts new file mode 100644 index 0000000..81feab2 --- /dev/null +++ b/core-graphql/src/resolvers/queries/wallet/wallet.ts @@ -0,0 +1,13 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; + +const databaseService = app.resolvePlugin("database"); + +/** + * Get a single wallet from the database + * @return {Wallet} + */ +export async function wallet(_, args: any) { + const param = args.address || args.publicKey || args.username; + return databaseService.wallets.findById(param); +} diff --git a/core-graphql/src/resolvers/queries/wallet/wallets.ts b/core-graphql/src/resolvers/queries/wallet/wallets.ts new file mode 100644 index 0000000..0ae2f10 --- /dev/null +++ b/core-graphql/src/resolvers/queries/wallet/wallets.ts @@ -0,0 +1,24 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; +import { formatOrderBy } from "../../../helpers"; + +const databaseService = app.resolvePlugin("database"); + +/** + * Get multiple wallets from the database + * @return {Wallet[]} + */ +export async function wallets(_, args: any) { + const { orderBy, filter, ...params } = args; + + const order = formatOrderBy(orderBy, "height:desc"); + const result = + filter && filter.vote + ? await databaseService.wallets.findAllByVote(filter.vote, { + orderBy: order, + ...params, + }) + : await databaseService.wallets.findAll({ orderBy: order, ...params }); + + return result ? result.rows : []; +} diff --git a/core-graphql/src/resolvers/relationship/block.ts b/core-graphql/src/resolvers/relationship/block.ts new file mode 100644 index 0000000..7457dfd --- /dev/null +++ b/core-graphql/src/resolvers/relationship/block.ts @@ -0,0 +1,43 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; +import { formatOrderBy, unserializeTransactions } from "../../helpers"; + +const databaseService = app.resolvePlugin("database"); + +/** + * Useful and common database operations with block data. + */ +export const Block = { + /** + * Get the transactions for a given block + * @param {Block}: block + * @param {Object}: args + * @return {Transaction[]} + */ + async transactions(block, args) { + const { orderBy, filter, ...params } = args; + + /* .findAll() method never existed on the TransactionRepository in core-database-postgres. This code would've blown chunks + const result = await database.connection.transactionsRepository.findAll( + { + ...filter, + orderBy: formatOrderBy(orderBy, "timestamp:DESC"), + ...params, + }, + false, + );*/ + const result = null; + const rows = result ? result.rows : []; + + return unserializeTransactions(rows); + }, + + /** + * Get the generator wallet for a given block + * @param {Block} block + * @return {Wallet} + */ + generator(block) { + return databaseService.wallets.findById(block.generatorPublicKey); + }, +}; diff --git a/core-graphql/src/resolvers/relationship/transaction.ts b/core-graphql/src/resolvers/relationship/transaction.ts new file mode 100644 index 0000000..b17119f --- /dev/null +++ b/core-graphql/src/resolvers/relationship/transaction.ts @@ -0,0 +1,32 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; + +const databaseService = app.resolvePlugin("database"); + +/** + * Useful and common database operations with transaction data. + */ +export const Transaction = { + /** + * Get the block of a transaction + * @param {Transaction} transaction + * @return {Block} + */ + block: transaction => databaseService.connection.blocksRepository.findById(transaction.blockId), + + /** + * Get the recipient of a transaction + * @param {Transaction} transaction + * @return {Wallet} + */ + recipient: transaction => + transaction.recipientId ? databaseService.wallets.findById(transaction.recipientId) : [], + + /** + * Get the sender of a transaction + * @param {Transaction} transaction + * @return {Wallet} + */ + sender: transaction => + transaction.senderPublicKey ? databaseService.wallets.findById(transaction.senderPublicKey) : [], +}; diff --git a/core-graphql/src/resolvers/relationship/wallet.ts b/core-graphql/src/resolvers/relationship/wallet.ts new file mode 100644 index 0000000..9fad8d5 --- /dev/null +++ b/core-graphql/src/resolvers/relationship/wallet.ts @@ -0,0 +1,68 @@ +import { app } from "@arkecosystem/core-container"; +import { Database } from "@arkecosystem/core-interfaces"; +import { formatOrderBy, unserializeTransactions } from "../../helpers"; + +const databaseService = app.resolvePlugin("database"); + +/** + * Useful and common database operations with wallet data. + */ +export const Wallet = { + /* + * Get the transactions for a given wallet. + * @param {Wallet} wallet + * @param {Object} args + * @return {Transaction[]} + */ + async transactions(wallet, args) { + const { orderBy, filter, ...params } = args; + + const walletOr = (databaseService.connection as any).createCondition("OR", [ + { + senderPublicKey: wallet.publicKey, + }, + { + recipientId: wallet.address, + }, + ]); + + /* TODO .findAll() method never existed on the TransactionRepository in core-database-postgres. This code would've blown chunks + const result = await databaseService.connection.transactionsRepository.findAll( + { + ...filter, + orderBy: formatOrderBy(orderBy, "timestamp:DESC"), + ...walletOr, + ...params, + }, + false, + );*/ + const result = null; + const rows = result ? result.rows : []; + + return unserializeTransactions(rows); + }, + + /* + * Get the blocks generated for a given wallet. + * @param {Wallet} wallet + * @param {Object} args + * @return {Block[]} + */ + blocks(wallet, args) { + const { orderBy, ...params } = args; + + params.generatorPublickKey = wallet.publicKey; + + /* TODO: .findAll() method never existed on the TransactionRepository in core-database-postgres. This code would've blown chunks + const result = databaseService.connection.blocksRepository.findAll( + { + orderBy: formatOrderBy(orderBy, "height:DESC"), + ...params, + }, + false, + );*/ + const result = null; + const rows = result ? result.rows : []; + return rows; + }, +}; diff --git a/core-graphql/src/server.ts b/core-graphql/src/server.ts new file mode 100644 index 0000000..59cdba2 --- /dev/null +++ b/core-graphql/src/server.ts @@ -0,0 +1,18 @@ +import { createServer, mountServer } from "@arkecosystem/core-http-utils"; +import { apolloServer } from "./apollo-server"; + +export async function startServer(config) { + const app = await createServer({ + host: config.host, + port: config.port, + }); + + await apolloServer.applyMiddleware({ + app, + path: config.path, + }); + + await apolloServer.installSubscriptionHandlers(app.listener); + + return mountServer("GraphQL", app); +} diff --git a/core-graphql/tsconfig.json b/core-graphql/tsconfig.json new file mode 100644 index 0000000..0b089c5 --- /dev/null +++ b/core-graphql/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/**.ts"] +} diff --git a/core-json-rpc/.gitattributes b/core-json-rpc/.gitattributes new file mode 100644 index 0000000..63f6a5b --- /dev/null +++ b/core-json-rpc/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/README.md export-ignore diff --git a/core-json-rpc/README.md b/core-json-rpc/README.md new file mode 100644 index 0000000..304ee14 --- /dev/null +++ b/core-json-rpc/README.md @@ -0,0 +1,21 @@ +# ARK Core - JSON-RPC + +

+ +

+ +## Documentation + +You can find installation instructions and detailed instructions on how to use this package at the [dedicated documentation site](https://docs.ark.io/guidebook/core/plugins/optional/core-json-rpc.html). + +## Security + +If you discover a security vulnerability within this package, please send an e-mail to security@ark.io. All security vulnerabilities will be promptly addressed. + +## Credits + +This project exists thanks to all the people who [contribute](../../../../contributors). + +## License + +[MIT](LICENSE) © [ARK Ecosystem](https://ark.io) diff --git a/core-json-rpc/package.json b/core-json-rpc/package.json new file mode 100644 index 0000000..9804e1c --- /dev/null +++ b/core-json-rpc/package.json @@ -0,0 +1,56 @@ +{ + "name": "@arkecosystem/core-json-rpc", + "version": "2.4.0-next.9", + "description": "A JSON-RPC 2.0 Specification compliant server to interact with the ARK Blockchain.", + "license": "MIT", + "contributors": [ + "François-Xavier Thoorens ", + "Brian Faust " + ], + "files": [ + "dist" + ], + "main": "dist/index", + "scripts": { + "build": "yarn clean && yarn compile", + "build:watch": "yarn clean && yarn compile -w", + "clean": "del dist", + "compile": "../../node_modules/typescript/bin/tsc", + "prepublishOnly": "yarn build", + "pretest": "bash ../../scripts/pre-test.sh" + }, + "dependencies": { + "@arkecosystem/core-container": "^2.4.0-next.9", + "@arkecosystem/core-http-utils": "^2.4.0-next.9", + "@arkecosystem/core-interfaces": "^2.4.0-next.9", + "@arkecosystem/core-utils": "^2.4.0-next.9", + "@arkecosystem/crypto": "^2.4.0-next.9", + "@hapi/boom": "^7.4.2", + "@keyv/sqlite": "^2.0.0", + "bip39": "^3.0.2", + "is-reachable": "^3.1.0", + "keyv": "^3.1.0", + "lodash.get": "^4.4.2", + "lodash.sample": "^4.2.1", + "uuid": "^3.3.2", + "wif": "^2.0.6" + }, + "devDependencies": { + "@arkecosystem/core-p2p": "^2.4.0-next.9", + "@types/bip39": "^2.4.2", + "@types/hapi__boom": "^7.4.0", + "@types/is-reachable": "^3.1.0", + "@types/keyv": "^3.1.0", + "@types/keyv__sqlite": "^2.0.0", + "@types/lodash.get": "^4.4.6", + "@types/lodash.sample": "^4.2.6", + "@types/uuid": "^3.4.4", + "@types/wif": "^2.0.1" + }, + "engines": { + "node": ">=10.x" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/core-json-rpc/src/defaults.ts b/core-json-rpc/src/defaults.ts new file mode 100644 index 0000000..c52ef1b --- /dev/null +++ b/core-json-rpc/src/defaults.ts @@ -0,0 +1,11 @@ +export const defaults = { + enabled: process.env.CORE_EXCHANGE_JSON_RPC_ENABLED, + host: process.env.CORE_EXCHANGE_JSON_RPC_HOST || "0.0.0.0", + port: process.env.CORE_EXCHANGE_JSON_RPC_PORT || 8080, + allowRemote: false, + whitelist: ["127.0.0.1", "::ffff:127.0.0.1"], + database: { + uri: process.env.CORE_EXCHANGE_JSON_RPC_DATABASE || `sqlite://${process.env.CORE_PATH_DATA}/json-rpc.sqlite`, + options: {}, + }, +}; diff --git a/core-json-rpc/src/index.ts b/core-json-rpc/src/index.ts new file mode 100644 index 0000000..ca2e9e2 --- /dev/null +++ b/core-json-rpc/src/index.ts @@ -0,0 +1,30 @@ +import { Container, Logger } from "@arkecosystem/core-interfaces"; +import { defaults } from "./defaults"; +import { startServer } from "./server"; +import { database } from "./server/services/database"; + +export const plugin: Container.IPluginDescriptor = { + pkg: require("../package.json"), + defaults, + alias: "json-rpc", + async register(container: Container.IContainer, options) { + const logger = container.resolvePlugin("logger"); + + if (!options.enabled) { + logger.info("JSON-RPC Server is disabled"); + + return undefined; + } + + database.init(options.database); + + return startServer(options); + }, + async deregister(container: Container.IContainer, options) { + if (options.enabled) { + container.resolvePlugin("logger").info("Stopping JSON-RPC Server"); + + return container.resolvePlugin("json-rpc").stop(); + } + }, +}; diff --git a/core-json-rpc/src/interfaces.ts b/core-json-rpc/src/interfaces.ts new file mode 100644 index 0000000..d2dcb00 --- /dev/null +++ b/core-json-rpc/src/interfaces.ts @@ -0,0 +1,29 @@ +import { Interfaces } from "@arkecosystem/crypto"; + +export interface IRequestParameters { + jsonrpc: "2.0"; + method: string; + id: string | number; + params: object; +} + +export interface IResponse { + jsonrpc: "2.0"; + id: string | number; + result: T; +} + +export interface IResponseError { + jsonrpc: "2.0"; + id: string | number; + error: { + code: number; + message: string; + data: string; + }; +} + +export interface IWallet { + keys: Interfaces.IKeyPair; + wif: string; +} diff --git a/core-json-rpc/src/server/index.ts b/core-json-rpc/src/server/index.ts new file mode 100755 index 0000000..3529751 --- /dev/null +++ b/core-json-rpc/src/server/index.ts @@ -0,0 +1,56 @@ +import { app } from "@arkecosystem/core-container"; +import { createServer, mountServer, plugins } from "@arkecosystem/core-http-utils"; +import { Logger } from "@arkecosystem/core-interfaces"; +import { IRequestParameters } from "../interfaces"; +import * as modules from "./modules"; +import { Processor } from "./services/processor"; + +export const startServer = async options => { + if (options.allowRemote) { + app.resolvePlugin("logger").warn( + "JSON-RPC server allows remote connections, this is a potential security risk", + ); + } + + const server = await createServer({ + host: options.host, + port: options.port, + }); + + // @ts-ignore + server.app.schemas = {}; + + if (!options.allowRemote) { + await server.register({ + plugin: plugins.whitelist, + options: { + whitelist: options.whitelist, + }, + }); + } + + for (const module of Object.values(modules)) { + for (const method of Object.values(module)) { + // @ts-ignore + server.app.schemas[method.name] = method.schema; + + delete method.schema; + + server.method(method); + } + } + + server.route({ + method: "POST", + path: "/", + async handler(request) { + const processor = new Processor(); + + return Array.isArray(request.payload) + ? processor.collection(request.server, request.payload as IRequestParameters[]) + : processor.resource(request.server, request.payload as IRequestParameters); + }, + }); + + return mountServer("JSON-RPC", server); +}; diff --git a/core-json-rpc/src/server/modules.ts b/core-json-rpc/src/server/modules.ts new file mode 100644 index 0000000..fd35ef7 --- /dev/null +++ b/core-json-rpc/src/server/modules.ts @@ -0,0 +1,390 @@ +import { Crypto, Identities, Interfaces, Transactions } from "@arkecosystem/crypto"; +import Boom from "@hapi/boom"; +import { generateMnemonic } from "bip39"; +import { IWallet } from "../interfaces"; +import { database } from "./services/database"; +import { network } from "./services/network"; +import { decryptWIF, getBIP38Wallet } from "./utils"; + +export const blocks = [ + { + name: "blocks.info", + async method(params: { id: string }) { + const response = await network.sendGET({ path: `blocks/${params.id}` }); + + if (!response) { + return Boom.notFound(`Block ${params.id} could not be found.`); + } + + return response.data; + }, + schema: { + type: "object", + properties: { + id: { blockId: {} }, + }, + required: ["id"], + }, + }, + { + name: "blocks.latest", + async method() { + const response = await network.sendGET({ + path: "blocks", + query: { orderBy: "height:desc", limit: 1 }, + }); + + return response ? response.data[0] : Boom.notFound(`Latest block could not be found.`); + }, + }, + { + name: "blocks.transactions", + async method(params: { id: string; offset?: number }) { + const response = await network.sendGET({ + path: `blocks/${params.id}/transactions`, + query: { + offset: params.offset, + orderBy: "timestamp:desc", + }, + }); + + if (!response) { + return Boom.notFound(`Block ${params.id} could not be found.`); + } + + return { + count: response.meta.totalCount, + data: response.data, + }; + }, + schema: { + type: "object", + properties: { + id: { blockId: {} }, + offset: { + type: "number", + }, + }, + required: ["id"], + }, + }, +]; + +export const transactions = [ + { + name: "transactions.broadcast", + async method(params: { id: string }) { + const transaction: Interfaces.ITransactionData = await database.get(params.id); + + if (!transaction) { + return Boom.notFound(`Transaction ${params.id} could not be found.`); + } + + const { data } = Transactions.TransactionFactory.fromData(transaction); + + if (!Transactions.Verifier.verifyHash(data)) { + return Boom.badData(); + } + + await network.sendPOST({ + path: "transactions", + body: { transactions: [transaction] }, + }); + + return transaction; + }, + schema: { + type: "object", + properties: { + id: { + $ref: "transactionId", + }, + }, + required: ["id"], + }, + }, + { + name: "transactions.create", + async method(params: { recipientId: string; amount: string; vendorField?: string; passphrase: string }) { + const transactionBuilder = Transactions.BuilderFactory.transfer() + .recipientId(params.recipientId) + .amount(params.amount); + + if (params.vendorField) { + transactionBuilder.vendorField(params.vendorField); + } + + const transaction: Interfaces.ITransactionData = transactionBuilder.sign(params.passphrase).getStruct(); + + if (!Transactions.Verifier.verifyHash(transaction)) { + return Boom.badData(); + } + + await database.set(transaction.id, transaction); + + return transaction; + }, + schema: { + type: "object", + properties: { + amount: { + type: "number", + }, + recipientId: { + type: "string", + $ref: "address", + }, + passphrase: { + type: "string", + }, + vendorField: { + type: "string", + }, + }, + required: ["amount", "recipientId", "passphrase"], + }, + }, + { + name: "transactions.info", + async method(params: { id: string }) { + const response = await network.sendGET({ path: `transactions/${params.id}` }); + + if (!response) { + return Boom.notFound(`Transaction ${params.id} could not be found.`); + } + + return response.data; + }, + schema: { + type: "object", + properties: { + id: { + $ref: "transactionId", + }, + }, + required: ["id"], + }, + }, + { + name: "transactions.bip38.create", + async method(params: { + userId: string; + bip38: string; + recipientId: string; + amount: string; + vendorField?: string; + }) { + try { + const wallet: IWallet = await getBIP38Wallet(params.userId, params.bip38); + + if (!wallet) { + return Boom.notFound(`User ${params.userId} could not be found.`); + } + + const transactionBuilder = Transactions.BuilderFactory.transfer() + .recipientId(params.recipientId) + .amount(params.amount); + + if (params.vendorField) { + transactionBuilder.vendorField(params.vendorField); + } + + const transaction: Interfaces.ITransactionData = transactionBuilder.signWithWif(wallet.wif).getStruct(); + + if (!Transactions.Verifier.verifyHash(transaction)) { + return Boom.badData(); + } + + await database.set(transaction.id, transaction); + + return transaction; + } catch (error) { + return Boom.badImplementation(error.message); + } + }, + schema: { + type: "object", + properties: { + amount: { + type: "number", + }, + recipientId: { + type: "string", + $ref: "address", + }, + vendorField: { + type: "string", + }, + bip38: { + type: "string", + }, + userId: { + type: "string", + $ref: "hex", + }, + }, + required: ["amount", "recipientId", "bip38", "userId"], + }, + }, +]; + +export const wallets = [ + { + name: "wallets.create", + async method(params: { passphrase: string }) { + const { publicKey }: Interfaces.IKeyPair = Identities.Keys.fromPassphrase(params.passphrase); + + return { + publicKey, + address: Identities.Address.fromPublicKey(publicKey), + }; + }, + schema: { + type: "object", + properties: { + passphrase: { + type: "string", + }, + }, + required: ["passphrase"], + }, + }, + { + name: "wallets.info", + async method(params: { address: string }) { + const response = await network.sendGET({ path: `wallets/${params.address}` }); + + if (!response) { + return Boom.notFound(`Wallet ${params.address} could not be found.`); + } + + return response.data; + }, + schema: { + type: "object", + properties: { + address: { + type: "string", + $ref: "address", + }, + }, + required: ["address"], + }, + }, + { + name: "wallets.transactions", + async method(params: { offset?: number; address: string }) { + const response = await network.sendGET({ + path: "transactions", + query: { + offset: params.offset || 0, + orderBy: "timestamp:desc", + ownerId: params.address, + }, + }); + + if (!response.data || !response.data.length) { + return Boom.notFound(`Wallet ${params.address} could not be found.`); + } + + return { + count: response.meta.totalCount, + data: response.data, + }; + }, + schema: { + type: "object", + properties: { + address: { + type: "string", + $ref: "address", + }, + offset: { + type: "integer", + }, + }, + required: ["address"], + }, + }, + { + name: "wallets.bip38.create", + async method(params: { userId: string; bip38: string }) { + try { + const { keys, wif }: IWallet = await getBIP38Wallet(params.userId, params.bip38); + + return { + publicKey: keys.publicKey, + address: Identities.Address.fromPublicKey(keys.publicKey), + wif, + }; + } catch (error) { + const { publicKey, privateKey }: Interfaces.IKeyPair = Identities.Keys.fromPassphrase( + generateMnemonic(), + ); + + const encryptedWIF: string = Crypto.bip38.encrypt( + Buffer.from(privateKey, "hex"), + true, + params.bip38 + params.userId, + ); + + await database.set( + Crypto.HashAlgorithms.sha256(Buffer.from(params.userId)).toString("hex"), + encryptedWIF, + ); + + return { + publicKey, + address: Identities.Address.fromPublicKey(publicKey), + wif: decryptWIF(encryptedWIF, params.userId, params.bip38).wif, + }; + } + }, + schema: { + type: "object", + properties: { + bip38: { + type: "string", + }, + userId: { + type: "string", + $ref: "hex", + }, + }, + required: ["bip38", "userId"], + }, + }, + { + name: "wallets.bip38.info", + async method(params: { userId: string; bip38: string }) { + const encryptedWIF: string = await database.get( + Crypto.HashAlgorithms.sha256(Buffer.from(params.userId)).toString("hex"), + ); + + if (!encryptedWIF) { + return Boom.notFound(`User ${params.userId} could not be found.`); + } + + const { keys, wif }: IWallet = decryptWIF(encryptedWIF, params.userId, params.bip38); + + return { + publicKey: keys.publicKey, + address: Identities.Address.fromPublicKey(keys.publicKey), + wif, + }; + }, + schema: { + type: "object", + properties: { + bip38: { + type: "string", + }, + userId: { + type: "string", + $ref: "hex", + }, + }, + required: ["bip38", "userId"], + }, + }, +]; diff --git a/core-json-rpc/src/server/services/database.ts b/core-json-rpc/src/server/services/database.ts new file mode 100644 index 0000000..52aba33 --- /dev/null +++ b/core-json-rpc/src/server/services/database.ts @@ -0,0 +1,19 @@ +import Keyv from "keyv"; + +class Database { + private database: Keyv; + + public init(options) { + this.database = new Keyv(options); + } + + public async get(id: string): Promise { + return this.database.get(id); + } + + public async set(id: string, value: T): Promise { + await this.database.set(id, value); + } +} + +export const database = new Database(); diff --git a/core-json-rpc/src/server/services/network.ts b/core-json-rpc/src/server/services/network.ts new file mode 100644 index 0000000..f6affad --- /dev/null +++ b/core-json-rpc/src/server/services/network.ts @@ -0,0 +1,81 @@ +import { app } from "@arkecosystem/core-container"; +import { Logger, P2P } from "@arkecosystem/core-interfaces"; +import { Peer } from "@arkecosystem/core-p2p"; +import { httpie } from "@arkecosystem/core-utils"; +import { Interfaces, Managers } from "@arkecosystem/crypto"; +import isReachable from "is-reachable"; +import sample from "lodash.sample"; + +class Network { + private readonly network: Interfaces.INetwork = Managers.configManager.get("network"); + private readonly logger: Logger.ILogger = app.resolvePlugin("logger"); + private readonly p2p: P2P.IPeerService = app.resolvePlugin("p2p"); + + public async sendGET({ path, query = {} }: { path: string; query?: Record }) { + return this.sendRequest("get", path, { query }); + } + + public async sendPOST({ path, body }: { path: string; body: Record }) { + return this.sendRequest("post", path, { body }); + } + + private async sendRequest(method: string, path: string, opts, tries: number = 0) { + try { + const peer: P2P.IPeer = await this.getPeer(); + const uri: string = `http://${peer.ip}:${peer.ports.api}/api/${path}`; + + this.logger.info(`Sending request on "${this.network.name}" to "${uri}"`); + + return (await httpie[method](uri, { + ...opts, + ...{ + headers: { + Accept: "application/vnd.core-api.v2+json", + "Content-Type": "application/json", + }, + timeout: 3000, + }, + })).body; + } catch (error) { + this.logger.error(error.message); + + if (tries > 3) { + this.logger.error(`Failed to find a responsive peer after 3 tries.`); + + return undefined; + } + + tries++; + + return this.sendRequest(method, path, opts, tries); + } + } + + private async getPeer(): Promise { + const peer: P2P.IPeer = sample(this.getPeers()); + + if (!(await isReachable(`${peer.ip}:${peer.port}`))) { + this.logger.warn(`${peer} is unresponsive. Choosing new peer.`); + + return this.getPeer(); + } + + return peer; + } + + private getPeers(): P2P.IPeer[] { + let peers: P2P.IPeer[] = this.p2p.getStorage().getPeers(); + + if (!peers.length && this.network.name === "testnet") { + peers = [new Peer("127.0.0.1")]; + } + + if (!peers.length) { + throw new Error("No peers found."); + } + + return peers; + } +} + +export const network = new Network(); diff --git a/core-json-rpc/src/server/services/processor.ts b/core-json-rpc/src/server/services/processor.ts new file mode 100644 index 0000000..c1854c2 --- /dev/null +++ b/core-json-rpc/src/server/services/processor.ts @@ -0,0 +1,101 @@ +import { Validation } from "@arkecosystem/crypto"; +import { Server } from "@hapi/hapi"; +import get from "lodash.get"; +import { IRequestParameters, IResponse, IResponseError } from "../../interfaces"; + +export class Processor { + public async resource( + server: Server, + payload: IRequestParameters, + ): Promise | IResponseError> { + const { error } = Validation.validator.validate( + { + type: "object", + properties: { + jsonrpc: { + type: "string", + pattern: "2.0", + }, + method: { + type: "string", + }, + id: { + type: ["number", "string"], + }, + params: { + type: "object", + }, + }, + required: ["jsonrpc", "method", "id"], + }, + payload || {}, + ); + + if (error) { + return this.createErrorResponse(payload ? payload.id : undefined, -32600, new Error(error)); + } + + const { method, params, id } = payload; + + try { + const targetMethod = get(server.methods, method); + + if (!targetMethod) { + return this.createErrorResponse(id, -32601, new Error("The method does not exist / is not available.")); + } + + // @ts-ignore + const schema = server.app.schemas[method]; + + if (schema) { + // tslint:disable-next-line:no-shadowed-variable + const { error } = Validation.validator.validate(schema, params); + + if (error) { + return this.createErrorResponse(id, -32602, error); + } + } + + const result = await targetMethod(params); + + return result.isBoom + ? this.createErrorResponse(id, result.output.statusCode, result.output.payload) + : this.createSuccessResponse(id, result); + } catch (error) { + return this.createErrorResponse(id, -32603, error); + } + } + + public async collection( + server: Server, + payloads: IRequestParameters[], + ): Promise> | IResponseError[]> { + const results = []; + + for (const payload of payloads) { + results.push(await this.resource(server, payload)); + } + + return results; + } + + private createSuccessResponse(id: string | number, result: T): IResponse { + return { + jsonrpc: "2.0", + id, + result, + }; + } + + private createErrorResponse(id: string | number, code: number, error: Error): IResponseError { + return { + jsonrpc: "2.0", + id, + error: { + code, + message: error.message, + data: error.stack, + }, + }; + } +} diff --git a/core-json-rpc/src/server/utils.ts b/core-json-rpc/src/server/utils.ts new file mode 100644 index 0000000..5061817 --- /dev/null +++ b/core-json-rpc/src/server/utils.ts @@ -0,0 +1,25 @@ +import { Crypto, Identities, Interfaces, Managers } from "@arkecosystem/crypto"; +import wif from "wif"; +import { IWallet } from "../interfaces"; +import { database } from "./services/database"; + +export const decryptWIF = (encryptedWif, userId, bip38password): IWallet => { + const decrypted: Interfaces.IDecryptResult = Crypto.bip38.decrypt( + encryptedWif.toString("hex"), + bip38password + userId, + ); + + const encodedWIF: string = wif.encode( + Managers.configManager.get("network.wif"), + decrypted.privateKey, + decrypted.compressed, + ); + + return { keys: Identities.Keys.fromWIF(encodedWIF), wif: encodedWIF }; +}; + +export const getBIP38Wallet = async (userId, bip38password): Promise => { + const encryptedWif: string = await database.get(Crypto.HashAlgorithms.sha256(Buffer.from(userId)).toString("hex")); + + return encryptedWif ? decryptWIF(encryptedWif, userId, bip38password) : undefined; +}; diff --git a/core-json-rpc/tsconfig.json b/core-json-rpc/tsconfig.json new file mode 100644 index 0000000..0b089c5 --- /dev/null +++ b/core-json-rpc/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/**.ts"] +} diff --git a/core-snapshots-cli/README.md b/core-snapshots-cli/README.md new file mode 100644 index 0000000..e3fb5d6 --- /dev/null +++ b/core-snapshots-cli/README.md @@ -0,0 +1,27 @@ +# ARK Core - Snapshots CLI + +

+ +

+ +## Deprecated + +Note that this plugin is deprecated and should no longer be used + +## Documentation + +You can find installation instructions and detailed instructions on how to use this package at the [dedicated documentation site](https://docs.ark.io/guidebook/core/plugins/core-snapshots-cli.html). + +## Security + +If you discover a security vulnerability within this package, please send an e-mail to security@ark.io. All security vulnerabilities will be promptly addressed. + +## Credits + +- [Joshua Noack](https://github.com/supaiku0) +- [Kristjan Košič](https://github.com/kristjank) +- [All Contributors](../../../../contributors) + +## License + +[MIT](LICENSE) © [ARK Ecosystem](https://ark.io) diff --git a/core-snapshots-cli/__tests__/.gitkeep b/core-snapshots-cli/__tests__/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/core-snapshots-cli/bin/run b/core-snapshots-cli/bin/run new file mode 100755 index 0000000..30b14e1 --- /dev/null +++ b/core-snapshots-cli/bin/run @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +require('@oclif/command').run() +.then(require('@oclif/command/flush')) +.catch(require('@oclif/errors/handle')) diff --git a/core-snapshots-cli/bin/run.cmd b/core-snapshots-cli/bin/run.cmd new file mode 100644 index 0000000..968fc30 --- /dev/null +++ b/core-snapshots-cli/bin/run.cmd @@ -0,0 +1,3 @@ +@echo off + +node "%~dp0\run" %* diff --git a/core-snapshots-cli/package.json b/core-snapshots-cli/package.json new file mode 100644 index 0000000..924b3f0 --- /dev/null +++ b/core-snapshots-cli/package.json @@ -0,0 +1,78 @@ +{ + "name": "@arkecosystem/core-snapshots-cli", + "version": "2.2.0", + "description": "Provides live cli interface to the core-snapshots module for ARK Core", + "license": "MIT", + "contributors": [ + "Kristjan Košič " + ], + "files": [ + "/bin", + "/dist", + "/oclif.manifest.json" + ], + "main": "dist/index.js", + "bin": { + "snapshot": "./bin/run" + }, + "scripts": { + "build": "yarn clean && yarn compile", + "build:watch": "yarn clean && yarn compile -w", + "clean": "del dist", + "compile": "../../node_modules/typescript/bin/tsc", + "debug": "node --inspect-brk ./dist/index.js", + "dump": "yarn snapshot dump", + "dump:devnet": "yarn snapshot dump --network devnet", + "dump:mainnet": "yarn snapshot dump --network mainnet", + "dump:testnet": "yarn snapshot dump --network testnet", + "prepack": "../../node_modules/.bin/oclif-dev manifest && npm shrinkwrap", + "postpack": "rm -f oclif.manifest.json", + "prepublishOnly": "yarn build", + "restore": "yarn snapshot restore", + "restore:devnet": "yarn snapshot restore --network devnet", + "restore:mainnet": "yarn snapshot restore --network mainnet", + "restore:testnet": "yarn snapshot restore --network testnet", + "rollback": "yarn snapshot rollback", + "rollback:devnet": "yarn snapshot rollback --network devnet", + "rollback:mainnet": "yarn snapshot rollback --network mainnet", + "rollback:testnet": "yarn snapshot rollback --network testnet", + "snapshot": "./bin/run", + "truncate": "yarn snapshot truncate", + "truncate:devnet": "yarn snapshot truncate --network devnet", + "truncate:mainnet": "yarn snapshot truncate --network mainnet", + "truncate:testnet": "yarn snapshot truncate --network testnet", + "verify": "yarn snapshot verify", + "verify:devnet": "yarn snapshot verify --network devnet", + "verify:mainnet": "yarn snapshot verify --network mainnet", + "verify:testnet": "yarn snapshot verify --network testnet" + }, + "dependencies": { + "@arkecosystem/core-container": "^2.2.0", + "@arkecosystem/core-interfaces": "^2.2.0", + "@arkecosystem/core-snapshots": "^2.2.0", + "@oclif/command": "^1.5.10", + "@oclif/config": "^1.12.6", + "@oclif/plugin-help": "^2.1.6", + "@oclif/plugin-not-found": "^1.2.2", + "@types/boom": "^7.2.1", + "@types/cli-progress": "^1.8.0", + "@types/commander": "^2.12.2", + "@types/fs-extra": "^5.0.5", + "cli-progress": "^2.1.1", + "fs-extra": "^7.0.1" + }, + "engines": { + "node": ">=10.x" + }, + "publishConfig": { + "access": "public" + }, + "oclif": { + "commands": "./dist/commands", + "bin": "snapshot", + "plugins": [ + "@oclif/plugin-help", + "@oclif/plugin-not-found" + ] + } +} diff --git a/core-snapshots-cli/src/commands/command.ts b/core-snapshots-cli/src/commands/command.ts new file mode 100644 index 0000000..c4f02e2 --- /dev/null +++ b/core-snapshots-cli/src/commands/command.ts @@ -0,0 +1,25 @@ +import Command, { flags } from "@oclif/command"; + +export abstract class BaseCommand extends Command { + public static flags = { + data: flags.string({ + description: "data directory", + }), + config: flags.string({ + description: "network config", + }), + token: flags.string({ + description: "token name", + default: "ark", + }), + network: flags.string({ + description: "token network", + }), + skipCompression: flags.boolean({ + description: "skip gzip compression", + }), + trace: flags.boolean({ + description: "dumps generated queries and settings to console", + }), + }; +} diff --git a/core-snapshots-cli/src/commands/dump.ts b/core-snapshots-cli/src/commands/dump.ts new file mode 100644 index 0000000..a06ac27 --- /dev/null +++ b/core-snapshots-cli/src/commands/dump.ts @@ -0,0 +1,38 @@ +import { app } from "@arkecosystem/core-container"; +import { Logger } from "@arkecosystem/core-interfaces"; +import { SnapshotManager } from "@arkecosystem/core-snapshots"; +import { flags } from "@oclif/command"; +import fs from "fs-extra"; +import { setUpLite } from "../utils"; +import { BaseCommand } from "./command"; + +export class DumpCommand extends BaseCommand { + public static description: string = "create a full snapshot of the database"; + + public static flags = { + ...BaseCommand.flags, + blocks: flags.string({ + description: "blocks to append to, correlates to folder name", + }), + start: flags.integer({ + description: "start network height to export", + default: -1, + }), + end: flags.integer({ + description: "end network height to export", + default: -1, + }), + codec: flags.string({ + description: "codec name, default is msg-lite binary", + }), + }; + + public async run(): Promise { + // tslint:disable-next-line:no-shadowed-variable + const { flags } = this.parse(DumpCommand); + + await setUpLite(flags); + + await app.resolvePlugin("snapshots").exportData(flags); + } +} diff --git a/core-snapshots-cli/src/commands/restore.ts b/core-snapshots-cli/src/commands/restore.ts new file mode 100644 index 0000000..0f2ea79 --- /dev/null +++ b/core-snapshots-cli/src/commands/restore.ts @@ -0,0 +1,61 @@ +import { app } from "@arkecosystem/core-container"; +import { EventEmitter } from "@arkecosystem/core-interfaces"; +import { SnapshotManager } from "@arkecosystem/core-snapshots"; +import { flags } from "@oclif/command"; +import _cliProgress from "cli-progress"; +import { setUpLite } from "../utils"; +import { BaseCommand } from "./command"; + +export class RestoreCommand extends BaseCommand { + public static description: string = "import data from specified snapshot"; + + public static flags = { + ...BaseCommand.flags, + blocks: flags.string({ + description: "blocks to import, correlates to folder name", + required: true, + }), + codec: flags.string({ + description: "codec name, default is msg-lite binary", + }), + truncate: flags.boolean({ + description: "empty all tables before running import", + }), + skipRestartRound: flags.boolean({ + description: "skip revert to current round", + }), + signatureVerify: flags.boolean({ + description: "signature verification", + }), + }; + + public async run(): Promise { + // tslint:disable-next-line:no-shadowed-variable + const { flags } = this.parse(RestoreCommand); + + await setUpLite(flags); + + const emitter = app.resolvePlugin("event-emitter"); + + const progressBar = new _cliProgress.Bar( + { + format: "{bar} {percentage}% | ETA: {eta}s | {value}/{total} | Duration: {duration}s", + }, + _cliProgress.Presets.shades_classic, + ); + + emitter.on("start", data => { + progressBar.start(data.count, 1); + }); + + emitter.on("progress", data => { + progressBar.update(data.value); + }); + + emitter.on("complete", data => { + progressBar.stop(); + }); + + await app.resolvePlugin("snapshots").importData(flags); + } +} diff --git a/core-snapshots-cli/src/commands/rollback.ts b/core-snapshots-cli/src/commands/rollback.ts new file mode 100644 index 0000000..dfa7011 --- /dev/null +++ b/core-snapshots-cli/src/commands/rollback.ts @@ -0,0 +1,35 @@ +import { app } from "@arkecosystem/core-container"; +import { Logger } from "@arkecosystem/core-interfaces"; +import { SnapshotManager } from "@arkecosystem/core-snapshots"; +import { flags } from "@oclif/command"; +import { setUpLite } from "../utils"; +import { BaseCommand } from "./command"; + +export class RollbackCommand extends BaseCommand { + public static description: string = "rollback chain to specified height"; + + public static flags = { + ...BaseCommand.flags, + height: flags.integer({ + description: "block network height number to rollback", + default: -1, + }), + }; + + public async run(): Promise { + // tslint:disable-next-line:no-shadowed-variable + const { flags } = this.parse(RollbackCommand); + + await setUpLite(flags); + + const logger = app.resolvePlugin("logger"); + + if (flags.height === -1) { + logger.warn("Rollback height is not specified. Rolling back to last completed round."); + } + + logger.info(`Starting the process of blockchain rollback to block height of ${flags.height.toLocaleString()}`); + + await app.resolvePlugin("snapshots").rollbackChain(flags.height); + } +} diff --git a/core-snapshots-cli/src/commands/truncate.ts b/core-snapshots-cli/src/commands/truncate.ts new file mode 100644 index 0000000..1d6a8fa --- /dev/null +++ b/core-snapshots-cli/src/commands/truncate.ts @@ -0,0 +1,17 @@ +import { app } from "@arkecosystem/core-container"; +import { SnapshotManager } from "@arkecosystem/core-snapshots"; +import { setUpLite } from "../utils"; +import { BaseCommand } from "./command"; + +export class TruncateCommand extends BaseCommand { + public static description: string = "truncate blockchain database"; + + public async run(): Promise { + // tslint:disable-next-line:no-shadowed-variable + const { flags } = this.parse(TruncateCommand); + + await setUpLite(flags); + + await app.resolvePlugin("snapshots").truncateChain(); + } +} diff --git a/core-snapshots-cli/src/commands/verify.ts b/core-snapshots-cli/src/commands/verify.ts new file mode 100644 index 0000000..a152cb9 --- /dev/null +++ b/core-snapshots-cli/src/commands/verify.ts @@ -0,0 +1,33 @@ +import { app } from "@arkecosystem/core-container"; +import { Logger } from "@arkecosystem/core-interfaces"; +import { SnapshotManager } from "@arkecosystem/core-snapshots"; +import { flags } from "@oclif/command"; +import fs from "fs-extra"; +import { setUpLite } from "../utils"; +import { BaseCommand } from "./command"; + +export class VerifyCommand extends BaseCommand { + public static description: string = "check validity of specified snapshot"; + + public static flags = { + ...BaseCommand.flags, + blocks: flags.string({ + description: "blocks to verify, correlates to folder name", + }), + codec: flags.string({ + description: "codec name, default is msg-lite binary", + }), + signatureVerify: flags.boolean({ + description: "signature verification", + }), + }; + + public async run(): Promise { + // tslint:disable-next-line:no-shadowed-variable + const { flags } = this.parse(VerifyCommand); + + await setUpLite(flags); + + await app.resolvePlugin("snapshots").verifyData(flags); + } +} diff --git a/core-snapshots-cli/src/index.ts b/core-snapshots-cli/src/index.ts new file mode 100644 index 0000000..8bdb76f --- /dev/null +++ b/core-snapshots-cli/src/index.ts @@ -0,0 +1 @@ +export { run } from "@oclif/command"; diff --git a/core-snapshots-cli/src/utils.ts b/core-snapshots-cli/src/utils.ts new file mode 100644 index 0000000..1ef2a1b --- /dev/null +++ b/core-snapshots-cli/src/utils.ts @@ -0,0 +1,18 @@ +import { app } from "@arkecosystem/core-container"; + +export const setUpLite = async options => { + process.env.CORE_SKIP_BLOCKCHAIN = "true"; + + await app.setUp("2.0.0", options, { + include: [ + "@arkecosystem/core-logger", + "@arkecosystem/core-logger-pino", + "@arkecosystem/core-event-emitter", + "@arkecosystem/core-snapshots", + ], + }); + + return app; +}; + +export const tearDown = async () => app.tearDown(); diff --git a/core-snapshots-cli/tsconfig.json b/core-snapshots-cli/tsconfig.json new file mode 100644 index 0000000..0b089c5 --- /dev/null +++ b/core-snapshots-cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/**.ts"] +}