diff --git a/package.json b/package.json index 2b50d28..9c95d7b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "devDependencies": { "@types/is-url": "^1.2.30", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.181", "@types/semver": "^7.3.9", "dotenv": "^16.0.0", "jest": "^27.5.1", @@ -32,6 +33,7 @@ "js-sha256": "^0.9.0", "js-yaml": "^4.1.0", "jsonschema": "^1.4.0", + "lodash": "^4.17.21", "node-yaml": "^4.0.1", "semver": "^7.3.5", "tiny-secp256k1": "^2.2.1" diff --git a/src/index.ts b/src/index.ts index f2f332c..981d508 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import { createTxMsgCancelBond, createTxMsgCreateBond, createTxMsgRefillBond, cr import { RegistryClient } from "./registry-client"; import { Account } from "./account"; import { createTransaction } from "./txbuilder"; -import { createTxMsgReserveAuthority, createTxMsgSetAuthorityBond, createTxMsgSetName, createTxMsgSetRecord, MessageMsgReserveAuthority, MessageMsgSetAuthorityBond, MessageMsgSetName, MessageMsgSetRecord } from './messages/nameservice'; +import { createTxMsgDeleteName, createTxMsgReserveAuthority, createTxMsgSetAuthorityBond, createTxMsgSetName, createTxMsgSetRecord, MessageMsgDeleteName, MessageMsgReserveAuthority, MessageMsgSetAuthorityBond, MessageMsgSetName, MessageMsgSetRecord } from './messages/nameservice'; import { Payload, Record } from './types'; const DEFAULT_WRITE_ERROR = 'Unable to write to chiba-clonk.'; @@ -82,6 +82,20 @@ export class Registry { return this._client.getAccount(address); } + /** + * Get records by attributes. + */ + async queryRecords(attributes: {[key: string]: any}, all = false, refs = false) { + return this._client.queryRecords(attributes, all, refs); + } + + /** + * Resolve names to records. + */ + async resolveNames(names: string[], refs = false) { + return this._client.resolveNames(names, refs); + } + /** * Publish record. * @param transactionPrivateKey - private key in HEX to sign transaction. @@ -245,7 +259,7 @@ export class Registry { /** * Cancel bond. */ - async cancelBond(params: MessageMsgCancelBond, senderAddress: string, privateKey: string, fee: Fee) { + async cancelBond(params: MessageMsgCancelBond, senderAddress: string, privateKey: string, fee: Fee) { let result; try { @@ -338,7 +352,7 @@ export class Registry { * @param {string} privateKey * @param {object} fee */ - async setName(params: MessageMsgSetName, senderAddress: string, privateKey: string, fee: Fee) { + async setName(params: MessageMsgSetName, senderAddress: string, privateKey: string, fee: Fee) { let result; try { @@ -361,6 +375,39 @@ export class Registry { return parseTxResponse(result); } + /** + * Lookup naming information. + */ + async lookupNames(names: string[], history = false) { + return this._client.lookupNames(names, history); + } + + /** + * Delete name (WRN) mapping. + */ + async deleteName(params: MessageMsgDeleteName, senderAddress: string, privateKey: string, fee: Fee) { + let result; + + try { + const { account: { base_account: accountInfo } } = await this.getAccount(senderAddress); + + const sender = { + accountAddress: accountInfo.address, + sequence: accountInfo.sequence, + accountNumber: accountInfo.account_number, + pubkey: accountInfo.pub_key.key, + } + + const msg = createTxMsgDeleteName(this._chain, sender, fee, '', params) + result = await this._submitTx(msg, privateKey, sender); + } catch (err: any) { + const error = err[0] || err; + throw new Error(Registry.processWriteError(error)); + } + + return parseTxResponse(result); + } + /** * Submit record transaction. * @param privateKey - private key in HEX to sign message. diff --git a/src/messages/nameservice.ts b/src/messages/nameservice.ts index 54cdefd..6da7a6f 100644 --- a/src/messages/nameservice.ts +++ b/src/messages/nameservice.ts @@ -60,6 +60,13 @@ const MSG_SET_AUTHORITY_BOND_TYPES = { ], } +const MSG_DELETE_NAME_TYPES = { + MsgValue: [ + { name: 'wrn', type: 'string' }, + { name: 'signer', type: 'string' }, + ], +} + export interface MessageMsgReserveAuthority { name: string owner: string @@ -80,6 +87,10 @@ export interface MessageMsgSetAuthorityBond { bondId: string } +export interface MessageMsgDeleteName { + wrn: string +} + export function createTxMsgReserveAuthority( chain: Chain, sender: Sender, @@ -176,6 +187,28 @@ export function createTxMsgSetAuthorityBond( return createTx(chain, sender, fee, memo, types, msg, msgCosmos) } +export function createTxMsgDeleteName( + chain: Chain, + sender: Sender, + fee: Fee, + memo: string, + params: MessageMsgDeleteName, +) { + const types = generateTypes(MSG_DELETE_NAME_TYPES) + + const msg = createMsgDeleteName( + params.wrn, + sender.accountAddress + ) + + const msgCosmos = protoCreateMsgDeleteName( + params.wrn, + sender.accountAddress + ) + + return createTx(chain, sender, fee, memo, types, msg, msgCosmos) +} + function createMsgReserveAuthority( name: string, signer: string, @@ -316,3 +349,31 @@ const protoCreateMsgSetAuthorityBond = ( path: 'vulcanize.nameservice.v1beta1.MsgSetAuthorityBond', } } + +function createMsgDeleteName( + wrn: string, + signer: string +) { + return { + type: 'nameservice/DeleteAuthority', + value: { + wrn, + signer + }, + } +} + +const protoCreateMsgDeleteName = ( + wrn: string, + signer: string +) => { + const deleteNameAutorityMessage = new nameserviceTx.vulcanize.nameservice.v1beta1.MsgDeleteNameAuthority({ + wrn, + signer, + }) + + return { + message: deleteNameAutorityMessage, + path: 'vulcanize.nameservice.v1beta1.MsgDeleteNameAuthority', + } +} diff --git a/src/naming.test.ts b/src/naming.test.ts index c619ef2..8006786 100644 --- a/src/naming.test.ts +++ b/src/naming.test.ts @@ -1,11 +1,13 @@ import assert from 'assert'; import path from 'path'; +import semver from 'semver'; import { Account } from './account'; import { Registry } from './index'; -import { ensureUpdatedConfig, getConfig } from './testing/helper'; +import { getBaseConfig, getConfig } from './testing/helper'; -const WATCHER_ID = 'bafyreibmr47ksukoadck2wigevb2jp5j5oubfadeyzb6zi57ydjsvjmmby' +const WATCHER_1_ID = 'bafyreif7z4lbftuxkj7nu4bl7xihitqvhus3wmoqdkjo7lwv24j6fkfskm' +const WATCHER_2_ID = 'bafyreiaplz7n2bddg3xhkeuiavwnku7xta2s2cfyaza37ytv4vw367exo4' const WATCHER_YML_PATH = path.join(__dirname, './testing/data/watcher.yml'); jest.setTimeout(120 * 1000); @@ -14,10 +16,15 @@ const { chainId, restEndpoint, gqlEndpoint, privateKey, accountAddress, fee } = const namingTests = () => { let registry: Registry; + let bondId: string; let watcher: any; let watcherId: string; + let authorityName: string; + let otherAuthorityName: string; + let otherPrivateKey: string; + let wrn: string; beforeAll(async () => { @@ -28,7 +35,8 @@ const namingTests = () => { await registry.createBond({ denom: 'aphoton', amount: '1000000000' }, accountAddress, privateKey, fee); // Create bot. - watcher = await ensureUpdatedConfig(WATCHER_YML_PATH); + // TODO: Use ensureUpdatedConfig from helper. + watcher = await getBaseConfig(WATCHER_YML_PATH); const result = await registry.setRecord( { privateKey, @@ -42,7 +50,7 @@ const namingTests = () => { // TODO: Get id from setRecord response. // watcherId = result.data; - watcherId = WATCHER_ID; + watcherId = WATCHER_1_ID; }); test('Reserve authority.', async () => { @@ -116,6 +124,142 @@ const namingTests = () => { test('Set authority bond', async () => { await registry.setAuthorityBond({ name: authorityName, bondId }, accountAddress, privateKey, fee); }); + + test('Set name', async () => { + wrn = `wrn://${authorityName}/app/test`; + await registry.setName({ wrn, cid: watcherId }, accountAddress, privateKey, fee); + + // Query records should return it (some WRN points to it). + const records = await registry.queryRecords({ type: 'watcher', version: watcher.record.version }); + expect(records).toBeDefined(); + // TODO: Fix queryRecords to return filtered results. + // expect(records).toHaveLength(1); + }); + + test('Lookup name', async () => { + const records = await registry.lookupNames([wrn]); + expect(records).toBeDefined(); + expect(records).toHaveLength(1); + + const [{ latest, history }] = records; + expect(latest).toBeDefined(); + expect(latest.id).toBeDefined(); + expect(latest.id).toBe(watcherId); + expect(latest.height).toBeDefined(); + expect(history).toBeUndefined(); + }); + + test('Resolve name', async () => { + const records = await registry.resolveNames([wrn]); + expect(records).toBeDefined(); + expect(records).toHaveLength(1); + + const [{ attributes }] = records; + // TODO: Set name with new record. + // expect(attributes).toEqual(watcher.record); + }); + + test('Lookup name with history', async () => { + // TODO: Use ensureUpdatedConfig from helper. + const updatedWatcher = await getBaseConfig(WATCHER_YML_PATH); + updatedWatcher.record.version = semver.inc(updatedWatcher.record.version, 'patch'); + const result = await registry.setRecord( + { + privateKey, + bondId, + record: updatedWatcher.record + }, + accountAddress, + privateKey, + fee + ) + + // TODO: Get id from setRecord response. + // const updatedWatcherId = result.data; + const updatedWatcherId = WATCHER_2_ID; + await registry.setName({ wrn, cid: updatedWatcherId }, accountAddress, privateKey, fee); + + const records = await registry.lookupNames([wrn], true); + expect(records).toHaveLength(1); + + const [{ latest, history }] = records; + expect(latest).toBeDefined(); + expect(latest.id).toBeDefined(); + expect(latest.id).toBe(updatedWatcherId); + expect(latest.height).toBeDefined(); + expect(history).toBeDefined(); + expect(history).toHaveLength(1); + + const [oldRecord] = history; + expect(oldRecord).toBeDefined(); + expect(oldRecord.id).toBeDefined(); + expect(oldRecord.id).toBe(watcherId); + expect(oldRecord.height).toBeDefined(); + }); + + xtest('Set name without reserving authority', async () => { + await expect(registry.setName({ wrn: 'wrn://not-reserved/app/test', cid: watcherId }, accountAddress, privateKey, fee)) + .rejects.toThrow('Name authority not found.'); + }); + + xtest('Set name for non-owned authority', async () => { + // Create another account. + const mnenonic = Account.generateMnemonic(); + const otherAccount = await Account.generateFromMnemonic(mnenonic); + await otherAccount.init() + assert(otherAccount.formattedCosmosAddress) + // TODO: Get correct account address from private key. + await registry.sendCoins({ denom: 'aphoton', amount: '1000000000', destinationAddress: otherAccount.formattedCosmosAddress }, accountAddress, privateKey, fee); + + // Other account reserves an authority. + otherAuthorityName = `other-${Date.now()}`; + otherPrivateKey = otherAccount.privateKey.toString('hex'); + await registry.reserveAuthority({ name: otherAuthorityName, owner: otherAccount.formattedCosmosAddress }, otherAccount.formattedCosmosAddress, otherPrivateKey, fee); + + // Try setting name under other authority. + await expect(registry.setName({ wrn: `wrn://${otherAuthorityName}/app/test`, cid: watcherId }, accountAddress, privateKey, fee)).rejects.toThrow('Access denied.'); + }); + + test('Lookup non existing name', async () => { + const records = await registry.lookupNames(['wrn://not-reserved/app/test']); + expect(records).toBeDefined(); + expect(records).toHaveLength(1); + const [record] = records; + expect(record).toBeNull(); + }); + + test('Resolve non existing name', async () => { + const records = await registry.resolveNames(['wrn://not-reserved/app/test']); + expect(records).toBeDefined(); + expect(records).toHaveLength(1); + const [record] = records; + expect(record).toBeNull(); + }); + + test('Delete name', async () => { + await registry.deleteName({ wrn }, accountAddress, privateKey, fee); + + let records = await registry.lookupNames([wrn], true); + expect(records).toBeDefined(); + expect(records).toHaveLength(1); + + const [{ latest }] = records; + expect(latest).toBeDefined(); + expect(latest.id).toBeDefined(); + expect(latest.id).toBe(''); + expect(latest.height).toBeDefined(); + + // Query records should NOT return it (no WRN points to it). + records = await registry.queryRecords({ type: 'watcher', version: watcher.record.version }); + expect(records).toBeDefined(); + // TODO: Fix queryRecords to return filtered results. + // expect(records).toHaveLength(0); + + // Query all records should return it (all: true). + records = await registry.queryRecords({ type: 'watcher', version: watcher.record.version }, true); + expect(records).toBeDefined(); + // expect(records).toHaveLength(1); + }); }; if (process.env.AUCTIONS_ENABLED) { diff --git a/src/registry-client.ts b/src/registry-client.ts index dc395ce..d9d0d44 100644 --- a/src/registry-client.ts +++ b/src/registry-client.ts @@ -1,10 +1,41 @@ import assert from 'assert'; import axios from 'axios'; import graphqlClient from 'graphql.js' -import { generateEndpointAccount, generateEndpointBroadcast, generatePostBodyBroadcast } from '@tharsis/provider'; +import { get, set } from 'lodash' +import { generateEndpointAccount, generateEndpointBroadcast } from '@tharsis/provider'; import { Util } from './util'; +const attributeField = ` + attributes { + key + value { + null + int + float + string + boolean + json + reference { + id + } + } + } +`; + +const refsField = ` + references { + id + } +`; + +const historyFields = ` + history { + id + height + } +`; + const auctionFields = ` id status @@ -64,7 +95,7 @@ export class RegistryClient { /** * Get query result. */ - static async getResult(query: any, key: string, modifier?: (rows: any[]) => {}) { + 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) { @@ -75,6 +106,19 @@ export class RegistryClient { return []; } + /** + * Prepare response attributes. + */ + static prepareAttributes(path: string) { + return (rows: any[]) => { + const result = rows.map(r => { + set(r, path, Util.fromGQLAttributes(get(r, path))); + return r; + }); + return result; + }; + } + /** * New Client. */ @@ -100,6 +144,38 @@ export class RegistryClient { return data } + /** + * Get records by attributes. + */ + async queryRecords(attributes: {[key: string]: any}, all = false, refs = false) { + if (!attributes) { + attributes = {}; + } + + const query = `query ($attributes: [KeyValueInput!], $all: Boolean) { + queryRecords(attributes: $attributes, all: $all) { + id + names + owners + bondId + createTime + expiryTime + ${attributeField} + ${refs ? refsField : ''} + } + }`; + + const variables = { + attributes: Util.toGQLAttributes(attributes), + all + }; + + let result = (await this._graph(query)(variables))['queryRecords']; + result = RegistryClient.prepareAttributes('attributes')(result); + + return result; + } + /** * Lookup authorities by names. */ @@ -127,6 +203,60 @@ export class RegistryClient { return result['lookupAuthorities']; } + /** + * Lookup names. + */ + async lookupNames(names: string[], history = false) { + assert(names.length); + + const query = `query ($names: [String!]) { + lookupNames(names: $names) { + latest { + id + height + } + ${history ? historyFields : ''} + } + }`; + + const variables = { + names + }; + + const result = await this._graph(query)(variables); + + return result['lookupNames']; + } + + /** + * Resolve names to records. + */ + async resolveNames(names: string[], refs = false) { + assert(names.length); + + const query = `query ($names: [String!]) { + resolveNames(names: $names) { + id + names + owners + bondId + createTime + expiryTime + ${attributeField} + ${refs ? refsField : ''} + } + }`; + + const variables = { + names + }; + + const result = (await this._graph(query)(variables))['resolveNames']; + result.records = RegistryClient.prepareAttributes('attributes')(result); + + return result; + } + /** * Get bonds by ids. */ diff --git a/src/testing/helper.ts b/src/testing/helper.ts index 92d60ef..8303c6d 100644 --- a/src/testing/helper.ts +++ b/src/testing/helper.ts @@ -10,6 +10,13 @@ export const ensureUpdatedConfig = async (path: string) => { return conf; }; +export const getBaseConfig = async (path: string) => { + const conf = await yaml.read(path); + conf.record.version = '0.0.1'; + + return conf; +}; + export const getConfig = () => { assert(process.env.PRIVATE_KEY); assert(process.env.ACCOUNT_ADDRESS); diff --git a/yarn.lock b/yarn.lock index ab56447..3d98cac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -724,6 +724,11 @@ jest-matcher-utils "^27.0.0" pretty-format "^27.0.0" +"@types/lodash@^4.14.181": + version "4.14.181" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.181.tgz#d1d3740c379fda17ab175165ba04e2d03389385d" + integrity sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag== + "@types/node@*", "@types/node@^17.0.21": version "17.0.23" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.23.tgz#3b41a6e643589ac6442bdbd7a4a3ded62f33f7da" @@ -2500,7 +2505,7 @@ lodash.memoize@4.x: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash@^4.7.0: +lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==