Add tests for querying names and deleteName
This commit is contained in:
parent
9de3c59c19
commit
f207be4838
@ -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"
|
||||
|
53
src/index.ts
53
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.
|
||||
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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);
|
||||
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user