Skip to content

Commit

Permalink
feat(cardano-services): metadata fetching logic
Browse files Browse the repository at this point in the history
- implement the main SP metadata fetching logic
  • Loading branch information
Ivaylo Andonov authored and AngelCastilloB committed Mar 17, 2023
1 parent 06db621 commit 3647598
Show file tree
Hide file tree
Showing 8 changed files with 468 additions and 65 deletions.
@@ -1,13 +1,23 @@
import { Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core';
/* eslint-disable max-len */
/* eslint-disable complexity */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable max-depth */

import * as Crypto from '@cardano-sdk/crypto';
import { CML, Cardano } from '@cardano-sdk/core';
import { Hash32ByteBase16 } from '@cardano-sdk/crypto';
import { HexBlob } from '@cardano-sdk/util';
import { Logger } from 'ts-log';
import { StakePoolExtMetadataResponse, StakePoolMetadataService } from '../types';
import { StakePoolMetadataResponse, StakePoolMetadataServiceError, StakePoolMetadataServiceFailure } from '../..';
import { ValidationError, validate } from 'jsonschema';
import { getExtMetadataUrl, getSchemaFormat, loadJsonSchema } from './util';
import { mapToExtendedMetadata } from './mappers';
import axios, { AxiosInstance } from 'axios';
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';

const HTTP_CLIENT_TIMEOUT = 1 * 1000;
const HTTP_CLIENT_TIMEOUT = 2 * 1000;
const HTTP_CLIENT_MAX_CONTENT_LENGTH = 5000;
const SERVICE_NAME = 'StakePoolMetadataService';

export const createHttpStakePoolMetadataService = (
logger: Logger,
Expand All @@ -16,38 +26,145 @@ export const createHttpStakePoolMetadataService = (
timeout: HTTP_CLIENT_TIMEOUT
})
): StakePoolMetadataService => ({
async getStakePoolExtendedMetadata(metadata: Cardano.StakePoolMetadata): Promise<Cardano.ExtendedStakePoolMetadata> {
async getStakePoolExtendedMetadata(
metadata: Cardano.StakePoolMetadata,
config: AxiosRequestConfig = {}
): Promise<Cardano.ExtendedStakePoolMetadata> {
const url = getExtMetadataUrl(metadata);
try {
logger.debug('About to fetch stake pool extended metadata');
const { data } = await axiosClient.get<StakePoolExtMetadataResponse>(url);
const { data } = await axiosClient.get<StakePoolExtMetadataResponse>(url, config);
const schema = loadJsonSchema(getSchemaFormat(metadata));
validate(data, schema, { throwError: true });
return mapToExtendedMetadata(data);
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
throw new ProviderError(
ProviderFailure.NotFound,
throw new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.FailedToFetchExtendedMetadata,
error,
`StakePoolMetadataService failed to fetch extended metadata from ${url} due to resource not found`
`${SERVICE_NAME} failed to fetch extended metadata from ${url} due to resource not found`
);
}

throw new ProviderError(
ProviderFailure.ConnectionFailure,
throw new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.FailedToFetchExtendedMetadata,
error,
`StakePoolMetadataService failed to fetch extended metadata from ${url} due to connection error`
`${SERVICE_NAME} failed to fetch extended metadata from ${url} due to connection error`
);
}

if (error instanceof ValidationError) {
throw new ProviderError(
ProviderFailure.InvalidResponse,
throw new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.InvalidExtendedMetadataFormat,
error,
'Extended metadata JSON format validation failed against the corresponding schema for correctness'
);
}

throw error;
}
},
async getStakePoolMetadata(hash: Hash32ByteBase16, url: string): Promise<StakePoolMetadataResponse> {
const errors = [];
let metadata: Cardano.StakePoolMetadata | undefined;
let extMetadata: Cardano.ExtendedStakePoolMetadata | undefined;

try {
logger.debug(`About to fetch stake pool metadata JSON from ${url}`);

// Fetch metadata as byte array
const { data } = await axiosClient.get<Uint8Array>(url, { responseType: 'arraybuffer' });

// Produce metadata hash
const metadataHash = Crypto.blake2b(Crypto.blake2b.BYTES).update(data).digest('hex');

// Verify base hashes
if (metadataHash !== hash) {
return {
errors: [
new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.InvalidStakePoolHash,
null,
`Invalid stake pool hash. Computed '${metadataHash}', expected '${hash}'`
)
],
metadata
};
}

// Transform fetched metadata from bytes array to JSON
metadata = JSON.parse(data.toString());

if (metadata?.extDataUrl || metadata?.extended) {
// Validate CIP-6 ext metadata fields
if (metadata.extDataUrl && (!metadata.extSigUrl || !metadata.extVkey)) {
return {
errors: [
new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.InvalidMetadata,
null,
'Missing ext signature or public key'
)
],
metadata
};
}

// Fetch extended metadata (supports both cip-6 and ada pools formats already)
extMetadata = await this.getStakePoolExtendedMetadata(metadata);

// In case of CIP-6 standard -> perform signature verification
if (metadata.extDataUrl && metadata.extSigUrl && metadata.extVkey) {
// Based on the CIP-6, we have `extSigUrl` (A URL with the extended metadata signature), so we need to make another HTTP request to get the actual signature
try {
const signature = (await axiosClient.get<Crypto.Ed25519SignatureHex>(metadata.extSigUrl)).data;
const message = HexBlob.fromBytes(Buffer.from(JSON.stringify(extMetadata)));
const publicKey = Crypto.Ed25519PublicKeyHex(metadata.extVkey);
const bip32Ed25519 = new Crypto.CmlBip32Ed25519(CML);

// Verify the signature
const isSignatureValid = await bip32Ed25519.verify(signature, message, publicKey);

// If not valid -> omit extended metadata from response and add specific error
if (!isSignatureValid) {
extMetadata = undefined;
errors.push(
new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.InvalidExtendedMetadataSignature,
null,
'Invalid extended metadata signature'
)
);
}

// If signature url failed -> omit extended metadata from response and add specific error
} catch (error) {
extMetadata = undefined;
errors.push(
new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.FailedToFetchExtendedSignature,
error,
`${SERVICE_NAME} failed to fetch extended signature from ${metadata.extSigUrl} due to connection error`
)
);
}
}
}
} catch (error) {
if (axios.isAxiosError(error)) {
errors.push(
new StakePoolMetadataServiceError(
StakePoolMetadataServiceFailure.FailedToFetchMetadata,
error.toJSON(),
`${SERVICE_NAME} failed to fetch metadata JSON from ${url} due to ${error.message}`
)
);
} else if (error instanceof StakePoolMetadataServiceError) {
errors.push(error);
}
}

return { errors, metadata: { ...metadata!, ext: extMetadata } };
}
});
@@ -0,0 +1,17 @@
import { ComposableError, formatErrorMessage } from '@cardano-sdk/util';

export enum StakePoolMetadataServiceFailure {
FailedToFetchExtendedMetadata = 'FAILED_TO_FETCH_EXTENDED_METADATA',
FailedToFetchMetadata = 'FAILED_TO_FETCH_METADATA',
FailedToFetchExtendedSignature = 'FAILED_TO_FETCH_EXTENDED_SIGNATURE',
InvalidExtendedMetadataFormat = 'INVALID_EXTENDED_METADATA_FORMAT',
InvalidExtendedMetadataSignature = 'INVALID_EXTENDED_METADATA_SIGNATURE',
InvalidStakePoolHash = 'INVALID_STAKE_POOL_HASH',
InvalidMetadata = 'INVALID_METADATA'
}

export class StakePoolMetadataServiceError<InnerError = unknown> extends ComposableError<InnerError> {
constructor(public reason: StakePoolMetadataServiceFailure, innerError?: InnerError, public detail?: string) {
super(formatErrorMessage(reason, detail), innerError);
}
}
Expand Up @@ -2,3 +2,4 @@ export * from './HttpStakePoolMetadataService';
export * from './types';
export * from './util';
export * from './mappers';
export * from './errors';
@@ -1,3 +1,6 @@
import { Cardano } from '@cardano-sdk/core';
import { CustomError } from 'ts-custom-error';

/**
* AdaPools format response types
* Based on: https://a.adapools.org/extended-example
Expand Down Expand Up @@ -89,3 +92,8 @@ export type Cip6ExtMetadataResponse = {
serial: number;
pool: Cip6ExtendedStakePoolMetadataFields;
};

export type StakePoolMetadataResponse = {
metadata: Cardano.StakePoolMetadata | undefined;
errors: CustomError[];
};
8 changes: 4 additions & 4 deletions packages/cardano-services/src/StakePool/types.ts
@@ -1,10 +1,10 @@
import { APExtMetadataResponse, Cip6ExtMetadataResponse } from './HttpStakePoolMetadata';
import { APExtMetadataResponse, Cip6ExtMetadataResponse, StakePoolMetadataResponse } from './HttpStakePoolMetadata';
import { Cardano } from '@cardano-sdk/core';
import { Hash32ByteBase16 } from '@cardano-sdk/crypto';

export interface StakePoolMetadataService {
getStakePoolExtendedMetadata(
poolMetadata: Cardano.StakePoolMetadata
): Promise<Cardano.ExtendedStakePoolMetadata | null>;
getStakePoolMetadata(hash: Hash32ByteBase16, url: string): Promise<StakePoolMetadataResponse>;
getStakePoolExtendedMetadata(poolMetadata: Cardano.StakePoolMetadata): Promise<Cardano.ExtendedStakePoolMetadata>;
}

export enum ExtMetadataFormat {
Expand Down

0 comments on commit 3647598

Please sign in to comment.