diff --git a/package.json b/package.json index a3b156b..f9214a0 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "axios": "^0.26.1", "ethers": "^5.6.1", "evmosjs": "^0.2.2", + "graphql.js": "^0.6.8", "is-url": "^1.2.4", "js-sha256": "^0.9.0", "ripemd160": "^2.0.2", diff --git a/src/bonds.test.ts b/src/bonds.test.ts index cc1b077..d406f96 100644 --- a/src/bonds.test.ts +++ b/src/bonds.test.ts @@ -1,7 +1,7 @@ import { Registry } from './index'; -import { getConfig } from './testing/helper'; +import { getConfig, wait } from './testing/helper'; -const { mockServer, chibaClonk: { chainId, endpoint, privateKey, accountAddress, fee } } = getConfig(); +const { mockServer, chibaClonk: { chainId, restEndpoint, gqlEndpoint, privateKey, accountAddress, fee } } = getConfig(); jest.setTimeout(90 * 1000); @@ -9,15 +9,41 @@ const bondTests = () => { let registry: Registry; let bondId1: string; + let bondOwner: string; + beforeAll(async () => { - registry = new Registry(endpoint, chainId); + registry = new Registry(restEndpoint, gqlEndpoint, chainId); }); test('Create bond.', async () => { bondId1 = await registry.getNextBondId(accountAddress); expect(bondId1).toBeDefined(); await registry.createBond({ denom: 'aphoton', amount: '100' }, accountAddress, privateKey, fee); + await wait(5000) }) + + test('Get bond by ID.', async () => { + const [bond] = await registry.getBondsByIds([bondId1]); + expect(bond).toBeDefined(); + expect(bond.id).toBe(bondId1); + expect(bond.balance).toHaveLength(1); + expect(bond.balance[0]).toEqual({ type: 'aphoton', quantity: '100' }); + bondOwner = bond.owner; + }); + + test('Query bonds.', async () => { + const bonds = await registry.queryBonds(); + expect(bonds).toBeDefined(); + const bond = bonds.filter((bond: any) => bond.id === bondId1); + expect(bond).toBeDefined(); + }); + + test('Query bonds by owner.', async () => { + const bonds = await registry.queryBonds({ owner: bondOwner }); + expect(bonds).toBeDefined(); + const bond = bonds.filter((bond: any) => bond.id === bondId1); + expect(bond).toBeDefined(); + }); }; if (mockServer) { diff --git a/src/graphql.d.ts b/src/graphql.d.ts new file mode 100644 index 0000000..5d35563 --- /dev/null +++ b/src/graphql.d.ts @@ -0,0 +1 @@ +declare module 'graphql.js' diff --git a/src/index.ts b/src/index.ts index 4d08f1a..dcc41c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,13 +53,17 @@ export class Registry { return message.log || DEFAULT_WRITE_ERROR; } - constructor(url: string, cosmosChainId = DEFAULT_CHAIN_ID) { - if (!isUrl(url)) { - throw new Error('Path to a registry GQL endpoint should be provided.'); + constructor(restUrl: string, gqlUrl: string, cosmosChainId = DEFAULT_CHAIN_ID) { + if (!isUrl(restUrl)) { + throw new Error('Path to a REST endpoint should be provided.'); } - this._endpoint = url; - this._client = new RegistryClient(url); + if (!isUrl(gqlUrl)) { + throw new Error('Path to a GQL endpoint should be provided.'); + } + + this._endpoint = restUrl; + this._client = new RegistryClient(restUrl, gqlUrl); this._chain = { chainId: 9000, @@ -85,7 +89,7 @@ export class Registry { const accountObj = account.base_account; const nextSeq = parseInt(accountObj.sequence, 10) + 1; - result = sha256(`${accountObj.address}:${accountObj.number}:${nextSeq}`); + result = sha256(`${accountObj.address}:${accountObj.account_number}:${nextSeq}`); } catch (err: any) { const error = err[0] || err; throw new Error(Registry.processWriteError(error)); @@ -94,6 +98,20 @@ export class Registry { return result; } + /** + * Get bonds by ids. + */ + async getBondsByIds(ids: string[]) { + return this._client.getBondsByIds(ids); + } + + /** + * Query bonds by attributes. + */ + async queryBonds(attributes = {}) { + return this._client.queryBonds(attributes); + } + /** * Create bond. */ diff --git a/src/registry-client.ts b/src/registry-client.ts index 14f6fe4..d4195f6 100644 --- a/src/registry-client.ts +++ b/src/registry-client.ts @@ -1,22 +1,43 @@ import assert from 'assert'; import axios from 'axios'; +import graphqlClient from 'graphql.js' import { generateEndpointAccount, generateEndpointBroadcast, generatePostBodyBroadcast } from '@tharsis/provider'; +import { Util } from './util'; + /** * Registry */ export class RegistryClient { - _endpoint: string + _restEndpoint: string + _graph: any + + /** + * Get query result. + */ + static async getResult(query: any, key: string, modifier?: (rows: any[]) => {}) { + const result = await query; + if (result && result[key] && result[key].length && result[key][0] !== null) { + if (modifier) { + return modifier(result[key]); + } + return result[key]; + } + return []; + } /** * New Client. - * @param {string} endpoint - * @param {object} options */ - constructor(endpoint: string) { - assert(endpoint); + constructor(restEndpoint: string, gqlEndpoint: string) { + assert(restEndpoint); - this._endpoint = endpoint; + this._restEndpoint = restEndpoint; + + this._graph = graphqlClient(gqlEndpoint, { + method: 'POST', + asJSON: true + }); } /** @@ -25,11 +46,58 @@ export class RegistryClient { async getAccount(address: string) { assert(address); - let { data } = await axios.get(`${this._endpoint}${generateEndpointAccount(address)}`) + let { data } = await axios.get(`${this._restEndpoint}${generateEndpointAccount(address)}`) return data } + /** + * Get bonds by ids. + */ + async getBondsByIds(ids: string[]) { + assert(ids); + assert(ids.length); + + const query = `query ($ids: [String!]) { + getBondsByIds(ids: $ids) { + id + owner + balance { + type + quantity + } + } + }`; + + const variables = { + ids + }; + + return RegistryClient.getResult(this._graph(query)(variables), 'getBondsByIds'); + } + + /** + * Get records by attributes. + */ + async queryBonds(attributes = {}) { + const query = `query ($attributes: [KeyValueInput!]) { + queryBonds(attributes: $attributes) { + id + owner + balance { + type + quantity + } + } + }`; + + const variables = { + attributes: Util.toGQLAttributes(attributes) + }; + + return RegistryClient.getResult(this._graph(query)(variables), 'queryBonds'); + } + /** * Submit transaction. */ @@ -38,7 +106,7 @@ export class RegistryClient { // Broadcast transaction. const { data } = await axios.post( - `${this._endpoint}${generateEndpointBroadcast()}`, + `${this._restEndpoint}${generateEndpointBroadcast()}`, tx ) diff --git a/src/testing/helper.ts b/src/testing/helper.ts index 03ae96a..2eb0161 100644 --- a/src/testing/helper.ts +++ b/src/testing/helper.ts @@ -1,5 +1,7 @@ -const DEFAULT_PRIVATE_KEY = '0451f0bd95c855d52e76cdc8dd06f29097b944bfef26d3455725157f9133f4e0'; -const DEFAULT_ADDRESS = 'ethm19n3je0lhuk0w9kmkftsuw4etn8lmpu3jjfayeh' +const DEFAULT_PRIVATE_KEY = '794ce0bf3c75571416001c3415e69059aeba54038bcac8ce5b9792259e6d193b'; +const DEFAULT_ADDRESS = 'ethm10atmndy7sm46829rc3yr7cxqucgrz5e9jg58xp' + +export const wait = (time: number) => new Promise(resolve => setTimeout(resolve, time)) export const getConfig = () => ({ mockServer: process.env.MOCK_SERVER || false, @@ -7,7 +9,8 @@ export const getConfig = () => ({ chainId: process.env.CHIBA_CLONK_CHAIN_ID || 'ethermint_9000-1', privateKey: DEFAULT_PRIVATE_KEY, accountAddress: DEFAULT_ADDRESS, - endpoint: process.env.CHIBA_CLONK_ENDPOINT || 'http://localhost:1317', + restEndpoint: process.env.CHIBA_CLONK_REST_ENDPOINT || 'http://localhost:1317', + gqlEndpoint: process.env.CHIBA_CLONK_GQL_ENDPOINT || 'http://localhost:9473/api', fee: { amount: '20', denom: 'aphoton', diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..5e11950 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,79 @@ +/** + * Utils + */ +export class Util { + /** + * Sorts JSON object. + */ + static sortJSON(object: any) { + if (object instanceof Array) { + for (let i = 0; i < object.length; i++) { + object[i] = Util.sortJSON(object[i]); + } + return object; + } + if (typeof object !== 'object' || object === null) return object; + + let keys = Object.keys(object); + keys = keys.sort(); + const newObject: {[key: string]: any} = {}; + + for (let i = 0; i < keys.length; i++) { + newObject[keys[i]] = Util.sortJSON(object[keys[i]]); + } + return newObject; + } + + /** + * Marshal object into gql 'attributes' variable. + */ + static toGQLAttributes(object: any) { + const vars: any[] = []; + + Object.keys(object).forEach(key => { + let type: string = typeof object[key]; + if (object[key] === null) { + vars.push({ key, value: { 'null': true } }); + } else if (type === 'number') { + type = (object[key] % 1 === 0) ? 'int' : 'float'; + vars.push({ key, value: { [type]: object[key] } }); + } else if (type === 'string') { + vars.push({ key, value: { 'string': object[key] } }); + } else if (type === 'boolean') { + vars.push({ key, value: { 'boolean': object[key] } }); + } else if (type === 'object') { + const nestedObject = object[key]; + if (nestedObject['/'] !== undefined) { + vars.push({ key, value: { 'reference': { id: nestedObject['/'] } } }); + } + } + }); + + return vars; + } + + /** + * Unmarshal attributes array to object. + */ + static fromGQLAttributes(attributes: any[] = []) { + const res: {[key: string]: any} = {}; + + attributes.forEach(attr => { + if (attr.value.null) { + res[attr.key] = null; + } else if (attr.value.json) { + res[attr.key] = JSON.parse(attr.value.json); + } else if (attr.value.reference) { + // Convert GQL reference to IPLD style link. + const ref = attr.value.reference; + res[attr.key] = { '/': ref.id }; + } else { + const { values, null: n, ...types } = attr.value; + const value = Object.values(types).find(v => v !== null); + res[attr.key] = value; + } + }); + + return res; + } +} diff --git a/yarn.lock b/yarn.lock index 71c852b..8145a01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2220,6 +2220,11 @@ graceful-fs@^4.2.9: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== +graphql.js@^0.6.8: + version "0.6.8" + resolved "https://registry.yarnpkg.com/graphql.js/-/graphql.js-0.6.8.tgz#5c2e57311b5e74c6665ff9394394bc76f273542f" + integrity sha512-y1OxsvPCfBell00yb2T1E+JQjFXzbmqDT3hsf7Ckof80DlRuQ3SrmLL7KC04Up81vlBj+l9opYJjDLf9OgMH3w== + has-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"