Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
.npmrc

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
Expand Down
6 changes: 3 additions & 3 deletions apps/account-api/src/controllers/v1/ics.controller.v1.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EnqueueService } from '#account-lib/services/enqueue-request.service';
import { AddNewPublicKeyAgreementRequestDto, IcsPublishAllRequestDto, UpsertPagePayloadDto } from '#types/dtos/account';
import { AccountIdDto } from '#types/dtos/common';
import { Body, Controller, HttpCode, HttpException, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, HttpCode, HttpException, HttpStatus, Param, Post } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiPromise } from '@polkadot/api';
import { SubmittableExtrinsic } from '@polkadot/api/types';
import { ISubmittableResult } from '@polkadot/types/types';
Expand Down Expand Up @@ -41,7 +41,7 @@ export class IcsControllerV1 {
payload: AddNewPublicKeyAgreementRequestDto,
api: ApiPromise,
): SubmittableExtrinsic<'promise', ISubmittableResult> {
const encodedPayload = this.blockchainService.createItemizedSignaturePayloadV2Type(payload.payload);
const encodedPayload = this.blockchainService.createItemizedSignaturePayloadV2Type(payload.payload).toU8a();
return api.tx.statefulStorage.applyItemActionsWithSignatureV2(
accountId,
chainSignature({ algo: 'Sr25519', encodedValue: payload.proof }), // TODO: determine signature algo
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
describe('KeysV1Controller', () => {

beforeEach(async () => {
});

afterAll(() => jest.restoreAllMocks());

});
25 changes: 23 additions & 2 deletions apps/account-api/src/controllers/v1/keys-v1.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ import { ReadOnlyGuard } from '#account-api/guards/read-only.guard';
import {
AddNewPublicKeyAgreementPayloadRequest,
AddNewPublicKeyAgreementRequestDto,
IcsProviderKeyPayload,
PublicKeyAgreementRequestDto,
PublicKeyAgreementsKeyPayload,
} from '#types/dtos/account/graphs.request.dto';
import { TransactionType } from '#types/account-webhook';
import { MsaIdDto } from '#types/dtos/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import { HexString } from '@polkadot/util/types';

@Controller({ version: '1', path: 'keys' })
@ApiTags('v1/keys')
Expand Down Expand Up @@ -83,8 +85,8 @@ export class KeysControllerV1 {
/**
* Using the provided query parameters, creates a new payload that can be signed to add new graph keys.
* @param queryParams - The query parameters for adding a new key
* @returns Payload is included for convenience. Encoded payload to be used when signing the transaction.
* @throws An error if the key already exists or the payload creation fails.
* @returns Payload is included for convenience. Encoded payload is signed to generate the proof for adding the key.
* @throws An error if the key already exists or the payload creation fails, or signature verification fails.
*/
async getPublicKeyAgreementsKeyPayload(
@Query() { msaId, newKey }: PublicKeyAgreementsKeyPayload,
Expand Down Expand Up @@ -113,4 +115,23 @@ export class KeysControllerV1 {
this.logger.info(`Add graph key in progress. referenceId: ${response.referenceId}`);
return response;
}

@Get('publicKeyAgreements/getAddIcsKeyPayload')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Get a properly encoded StatefulStorageItemizedSignaturePayloadV2 that can be signed.' })
@ApiOkResponse({
description: 'Returned an encoded StatefulStorageItemizedSignaturePayloadV2 for signing',
type: AddNewPublicKeyAgreementPayloadRequest,
})
/**
* Using the provided query parameters, creates a new payload that can be signed to add a new ICS key.
* @param queryParams - The query parameters for adding a new ICS key
* @returns Payload is included for convenience. Encoded payload is signed to generate the proof for adding the key.
* @throws An error if the key already exists, if the msaId does not exist, or if the newKey is not a valid ed25519 public key.
*/
async getAddIcsKeyPayload(
@Query() { msaId, newKey, keyType }: IcsProviderKeyPayload,
): Promise<AddNewPublicKeyAgreementPayloadRequest> {
return this.keysService.getAddIcsPublicKeyAgreementPayload(msaId, newKey);
}
}
78 changes: 76 additions & 2 deletions apps/account-api/src/services/keys.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import {
} from '#types/dtos/account';
import {
createAddKeyData,
createItemizedAddAction,
createItemizedDeleteAction,
createItemizedSignaturePayloadV2,
getUnifiedPublicKey,
sign as signEthereum,
createItemizedAddAction,
createItemizedDeleteAction,
} from '@frequency-chain/ethereum-utils';
import { u8aToHex } from '@polkadot/util';

import { KeysService } from '#account-api/services/keys.service';
import { Test } from '@nestjs/testing';
import { BlockchainRpcQueryService } from '#blockchain/blockchain-rpc-query.service';
Expand All @@ -28,6 +29,11 @@ import { getPinoHttpOptions } from '#logger-lib';
import { mockRedisProvider } from '#testlib';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { useContainer } from 'class-validator';
import Keyring from '@polkadot/keyring';
import { ItemizedStoragePageResponse } from '@frequency-chain/api-augment/interfaces';
import { Vec } from '@polkadot/types';
import IcsApi from '@projectlibertylabs-ics/ics-sdk';
const icsSdk: IcsApi = new IcsApi();

jest.mock<typeof import('#blockchain/blockchain-rpc-query.service')>('#blockchain/blockchain-rpc-query.service');
jest.mock<typeof import('#account-lib/services/enqueue-request.service')>(
Expand All @@ -36,6 +42,7 @@ jest.mock<typeof import('#account-lib/services/enqueue-request.service')>(

describe('KeysService', () => {
let keysService: KeysService;
let blockchainRpcQueryService: BlockchainRpcQueryService;

beforeAll(async () => {
await cryptoWaitReady();
Expand Down Expand Up @@ -75,6 +82,7 @@ describe('KeysService', () => {
useContainer(moduleRef, { fallbackOnErrors: true });

keysService = moduleRef.get(KeysService);
blockchainRpcQueryService = moduleRef.get(BlockchainRpcQueryService);
});

describe('Dtos', () => {
Expand Down Expand Up @@ -150,4 +158,70 @@ describe('KeysService', () => {
expect(keysService.verifyPublicKeyAgreementSignature(payload)).toBeTruthy();
});
});
describe('getAddIcsPublicKeyAgreementPayload', () => {
let keyring = new Keyring({ type: 'ed25519' });

let createItemizedSignaturePayloadSpy;
const msaId = '1234';
const seed = new Uint8Array(32).fill(1);
const newKeypair = keyring.addFromSeed(seed);

const mockItemizedStorageResponse = (returnItems: Array<any>): ItemizedStoragePageResponse => {
return {
items: { toArray: () => returnItems as any as Vec<any> },
content_hash: { toNumber: () => 5 },
} as unknown as ItemizedStoragePageResponse;
};

beforeEach(async () => {
jest.spyOn(blockchainRpcQueryService, 'getLatestSchemaIdForIntent').mockResolvedValueOnce(16299);
jest.spyOn(blockchainRpcQueryService, 'getLatestBlockNumber').mockResolvedValueOnce(300_000);
createItemizedSignaturePayloadSpy = jest
.spyOn(blockchainRpcQueryService, 'createItemizedSignaturePayloadV2Type')
.mockReturnValueOnce({
toU8a: () => Uint8Array.from([1, 2, 3]),
});
});
afterEach(() => jest.restoreAllMocks());

it('throws when the key is malformed', async () => {
jest.spyOn(blockchainRpcQueryService, 'isValidMsaId').mockResolvedValueOnce(true);
const badKey = '0xdeadabeef' as HexString;
await expect(() => keysService.getAddIcsPublicKeyAgreementPayload(msaId, badKey)).rejects.toThrow(
'ed25519 public key should be 32 bytes',
);
});

it('throws when the msaId does not exist', async () => {
jest.spyOn(blockchainRpcQueryService, 'isValidMsaId').mockResolvedValueOnce(false);
await expect(() =>
keysService.getAddIcsPublicKeyAgreementPayload(msaId, `0x${newKeypair.address}`)
).rejects.toThrow('MSA ID 1234 not found');
});

it('throws when the ICS key is already registered', async () => {
const newKey = newKeypair.publicKey;
const icsSerializedKey = icsSdk.serializePublicKey(newKey);
const mockPayload = [{ payload: icsSerializedKey }];
const mockResponse = mockItemizedStorageResponse(mockPayload);

jest.spyOn(blockchainRpcQueryService, 'getItemizedStorage').mockResolvedValueOnce(mockResponse);
jest.spyOn(blockchainRpcQueryService, 'isValidMsaId').mockResolvedValueOnce(true);

await expect(() =>
keysService.getAddIcsPublicKeyAgreementPayload(msaId, u8aToHex(newKeypair.publicKey)),
).rejects.toThrow('Requested key already exists!');
});

it('returns a payload when provided valid parameters', async () => {
const mockResponse = mockItemizedStorageResponse([]);
jest.spyOn(blockchainRpcQueryService, 'getItemizedStorage').mockResolvedValueOnce(mockResponse);
jest.spyOn(blockchainRpcQueryService, 'isValidMsaId').mockResolvedValueOnce(true);

const result = await keysService.getAddIcsPublicKeyAgreementPayload(msaId, u8aToHex(newKeypair.publicKey));
expect(result.encodedPayload).toEqual('0x010203');
expect(result.payload).toBeDefined();
expect(createItemizedSignaturePayloadSpy).toHaveBeenCalled();
});
});
});
51 changes: 50 additions & 1 deletion apps/account-api/src/services/keys.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { KeysResponse } from '#types/dtos/account/keys.response.dto';
import { ConflictException, Inject, Injectable, NotFoundException, OnApplicationBootstrap } from '@nestjs/common';
import { HexString } from '@polkadot/util/types';
import { hexToU8a } from '@polkadot/util';
import {
AddNewPublicKeyAgreementPayloadRequest,
AddNewPublicKeyAgreementRequestDto,
Expand Down Expand Up @@ -31,13 +32,18 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRedis } from '@songkeys/nestjs-redis';
import Redis from 'ioredis';
import { HOUR, MILLISECONDS_PER_SECOND } from 'time-constants';
import IcsApi from '@projectlibertylabs-ics/ics-sdk';
const icsSdk: IcsApi = new IcsApi();


const SCHEMA_ID_TTL = HOUR / MILLISECONDS_PER_SECOND; // 1 hour in seconds
const GRAPH_KEY_SCHEMA_ID_CACHE_KEY = 'graphKeySchemaId';
const ICS_KEY_SCHEMA_ID_CACHE_KEY = 'icsKeySchemaId';

@Injectable()
export class KeysService implements OnApplicationBootstrap {
private graphKeyIntentId: number;
private icsKeyIntentId: number;

async onApplicationBootstrap() {
try {
Expand All @@ -46,10 +52,17 @@ export class KeysService implements OnApplicationBootstrap {
'public-key-key-agreement',
);
this.graphKeyIntentId = intentId;

const { intentId: icsIntentId, schemaId: icsSchemaId } = await this.blockchainService.getIntentAndLatestSchemaIdsByName(
'ics',
'public-key-key-agreement',
)
this.icsKeyIntentId = icsIntentId;
// Set 1-hour TTL on this so that we don't need to restart if a new schema is published
await this.cache.setex(GRAPH_KEY_SCHEMA_ID_CACHE_KEY, SCHEMA_ID_TTL, schemaId);
await this.cache.setex(ICS_KEY_SCHEMA_ID_CACHE_KEY, SCHEMA_ID_TTL, icsSchemaId);
} catch (e: any) {
this.logger.fatal({ error: e }, 'Unable to resolve intent ID for "dsnp.public-key-key-agreement"');
this.logger.fatal({ error: e }, 'Unable to resolve intent ID for one of the key agreements');
this.emitter.emit('shutdown');
}
}
Expand Down Expand Up @@ -122,6 +135,42 @@ export class KeysService implements OnApplicationBootstrap {
};
}

async getAddIcsPublicKeyAgreementPayload(
msaId: string,
newKeyHex: HexString,
): Promise<AddNewPublicKeyAgreementPayloadRequest> {
const isMsaId = await this.blockchainService.isValidMsaId(msaId);
if (!isMsaId) {
throw new NotFoundException(`MSA ID ${msaId} not found`);
}
const expiration = await this.getExpiration();
const schemaId = await this.blockchainService.getLatestSchemaIdForIntent(this.icsKeyIntentId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check Redis first, so we don't need to query the chain for the latest schema every time?


const newKeyU8a = hexToU8a(newKeyHex);
const itemizedStorage = await this.blockchainService.getItemizedStorage(msaId, this.icsKeyIntentId);

const icsSerializedKeyForStorage = icsSdk.serializePublicKey(newKeyU8a);
const encodedSerializedKey: HexString = u8aToHex(icsSerializedKeyForStorage);

const foundKey = itemizedStorage.items.toArray().find((i) => i.payload.length > 0
&& u8aToHex(i.payload).toLowerCase() === encodedSerializedKey.toLowerCase()
)

if (foundKey) {
throw new ConflictException('Requested key already exists!');
}
const payload: ItemizedSignaturePayloadDto = {
expiration, schemaId, targetHash: itemizedStorage.content_hash.toNumber(),
actions: [{ type: ItemActionType.ADD_ITEM, encodedPayload: encodedSerializedKey }]
}

const encodedPayload = u8aToHex(this.blockchainService.createItemizedSignaturePayloadV2Type(payload).toU8a());
return {
payload,
encodedPayload,
};
}

private async getExpiration(): Promise<number> {
const lastFinalizedBlockNumber = await this.blockchainService.getLatestBlockNumber();
// standard expiration in SIWF is 10 minutes
Expand Down
Loading
Loading