diff --git a/metadata-protobuf/proto/App.proto b/metadata-protobuf/proto/App.proto new file mode 100644 index 0000000000..013ee2eda4 --- /dev/null +++ b/metadata-protobuf/proto/App.proto @@ -0,0 +1,23 @@ +syntax = "proto2"; + +message AppActionMetadata { + // YouTube video ID + optional string video_id = 2; +} + +message AppAction { + // ID of application + required string app_id = 999; + + // Metadata + optional bytes metadata = 2; + + // Raw metadata of wrapped action + optional bytes raw_action = 3; + + // Signature over app commitment + optional bytes signature = 4; + + // Nonce to prevent signature reusal + optional uint32 nonce = 5; +} diff --git a/query-node/mappings/src/common.ts b/query-node/mappings/src/common.ts index 04cb82713e..bb72ab75cd 100644 --- a/query-node/mappings/src/common.ts +++ b/query-node/mappings/src/common.ts @@ -114,7 +114,10 @@ export function invalidMetadata(extraInfo: string, data?: unknown): void { export function deserializeMetadata( metadataType: AnyMetadataClass, - metadataBytes: Bytes + metadataBytes: Bytes, + opts = { + skipWarning: false, + } ): DecodedMetadataObject | null { try { const message = metadataType.decode(metadataBytes.toU8a(true)) @@ -125,7 +128,9 @@ export function deserializeMetadata( }) return metaToObject(metadataType, message) } catch (e) { - invalidMetadata(`Cannot deserialize ${metadataType.name}! Provided bytes: (${metadataBytes.toHex()})`) + if (!opts.skipWarning) { + invalidMetadata(`Cannot deserialize ${metadataType.name}! Provided bytes: (${metadataBytes.toHex()})`) + } return null } } diff --git a/query-node/mappings/src/content/app.ts b/query-node/mappings/src/content/app.ts index f98a927559..8c174f6cab 100644 --- a/query-node/mappings/src/content/app.ts +++ b/query-node/mappings/src/content/app.ts @@ -129,7 +129,7 @@ export async function processDeleteAppMessage( logger.info('App has been removed', { appId }) } -async function getAppById(store: DatabaseManager, appId: string): Promise { +export async function getAppById(store: DatabaseManager, appId: string): Promise { const app = await store.get(App, { where: { id: appId, diff --git a/query-node/mappings/src/content/channel.ts b/query-node/mappings/src/content/channel.ts index 4df247dd09..4f6634ea3f 100644 --- a/query-node/mappings/src/content/channel.ts +++ b/query-node/mappings/src/content/channel.ts @@ -2,7 +2,12 @@ eslint-disable @typescript-eslint/naming-convention */ import { DatabaseManager, EventContext, StoreContext, SubstrateEvent } from '@joystream/hydra-common' -import { ChannelMetadata, ChannelModeratorRemarked, ChannelOwnerRemarked } from '@joystream/metadata-protobuf' +import { + AppAction, + ChannelMetadata, + ChannelModeratorRemarked, + ChannelOwnerRemarked, +} from '@joystream/metadata-protobuf' import { ChannelId, DataObjectId } from '@joystream/types/primitives' import { Channel, @@ -42,6 +47,9 @@ import { processChannelMetadata, unsetAssetRelations, mapAgentPermission, + processAppActionMetadata, + generateAppActionCommitment, + u8aToBytes, } from './utils' import { BTreeMap, BTreeSet, u64 } from '@polkadot/types' // Joystream types @@ -53,6 +61,9 @@ export async function content_ChannelCreated(ctx: EventContext & StoreContext): const [channelId, { owner, dataObjects, channelStateBloatBond }, channelCreationParameters, rewardAccount] = new Content.ChannelCreatedEvent(event).params + // prepare channel owner (handles fields `ownerMember` and `ownerCuratorGroup`) + const channelOwner = await convertChannelOwnerToMemberOrCuratorGroup(store, owner) + // create entity const channel = new Channel({ // main data @@ -61,10 +72,7 @@ export async function content_ChannelCreated(ctx: EventContext & StoreContext): videos: [], createdInBlock: event.blockNumber, activeVideosCounter: 0, - - // prepare channel owner (handles fields `ownerMember` and `ownerCuratorGroup`) - ...(await convertChannelOwnerToMemberOrCuratorGroup(store, owner)), - + ...channelOwner, rewardAccount: rewardAccount.toString(), channelStateBloatBond: channelStateBloatBond.amount, }) @@ -77,13 +85,37 @@ export async function content_ChannelCreated(ctx: EventContext & StoreContext): inconsistentState(`storageBag for channel ${channelId} does not exist`) } - const metadata = deserializeMetadata(ChannelMetadata, channelCreationParameters.meta.unwrap()) || {} - await processChannelMetadata(ctx, channel, metadata, dataObjects) + const appAction = deserializeMetadata(AppAction, channelCreationParameters.meta.unwrap(), { skipWarning: true }) + + if (appAction) { + const channelMetadataBytes = u8aToBytes(appAction.rawAction) + const channelMetadata = deserializeMetadata(ChannelMetadata, channelMetadataBytes) + const appCommitment = generateAppActionCommitment( + channelOwner.ownerMember?.totalChannelsCreated ?? -1, + channelOwner.ownerMember?.id ? `m:${channelOwner.ownerMember.id}` : `c:${channelOwner.ownerCuratorGroup?.id}`, + channelCreationParameters.assets.toU8a(), + appAction.rawAction ? channelMetadataBytes : undefined, + appAction.metadata ? u8aToBytes(appAction.metadata) : undefined + ) + await processAppActionMetadata( + ctx, + channel, + appAction, + { ownerNonce: channelOwner.ownerMember?.totalChannelsCreated, appCommitment }, + (entity: Channel) => processChannelMetadata(ctx, entity, channelMetadata ?? {}, dataObjects) + ) + } else { + const channelMetadata = deserializeMetadata(ChannelMetadata, channelCreationParameters.meta.unwrap()) ?? {} + await processChannelMetadata(ctx, channel, channelMetadata, dataObjects) + } } // save entity await store.save(channel) - + if (channelOwner.ownerMember) { + channelOwner.ownerMember.totalChannelsCreated += 1 + await store.save(channelOwner.ownerMember) + } // update channel permissions await updateChannelAgentsPermissions(store, channel, channelCreationParameters.collaborators) @@ -117,8 +149,16 @@ export async function content_ChannelUpdated(ctx: EventContext & StoreContext): inconsistentState(`storageBag for channel ${channelId} does not exist`) } - const newMetadata = deserializeMetadata(ChannelMetadata, newMetadataBytes) || {} - await processChannelMetadata(ctx, channel, newMetadata, newDataObjects) + const newMetadata = deserializeMetadata(AppAction, newMetadataBytes, { skipWarning: true }) + + if (newMetadata) { + const channelMetadataBytes = u8aToBytes(newMetadata.rawAction) + const channelMetadata = deserializeMetadata(ChannelMetadata, channelMetadataBytes) + await processChannelMetadata(ctx, channel, channelMetadata ?? {}, newDataObjects) + } else { + const realNewMetadata = deserializeMetadata(ChannelMetadata, newMetadataBytes) + await processChannelMetadata(ctx, channel, realNewMetadata ?? {}, newDataObjects) + } } // save channel diff --git a/query-node/mappings/src/content/utils.ts b/query-node/mappings/src/content/utils.ts index 662c472e59..8a28b522c6 100644 --- a/query-node/mappings/src/content/utils.ts +++ b/query-node/mappings/src/content/utils.ts @@ -6,8 +6,10 @@ import { IMediaType, IChannelMetadata, ISubtitleMetadata, + IAppAction, } from '@joystream/metadata-protobuf' import { integrateMeta, isSet, isValidLanguageCode } from '@joystream/metadata-protobuf/utils' +import { ed25519Verify } from '@polkadot/util-crypto' import { invalidMetadata, inconsistentState, logger, deterministicEntityId, EntityType } from '../common' import { // primary entities @@ -19,6 +21,7 @@ import { Language, License, VideoMediaMetadata, + App, // asset Membership, VideoMediaEncoding, @@ -47,6 +50,10 @@ import _ from 'lodash' import { getSortedDataObjectsByIds } from '../storage/utils' import { BTreeSet } from '@polkadot/types' import { DataObjectId } from '@joystream/types/primitives' +import { Bytes } from '@polkadot/types/primitive' +import { u8aToHex, stringToHex } from '@polkadot/util' +import { createType } from '@joystream/types' +import { getAppById } from './app' const ASSET_TYPES = { channel: [ @@ -166,6 +173,76 @@ async function processVideoSubtitleAssets( } } +async function validateAndGetApp( + ctx: EventContext & StoreContext, + validationContext: { + ownerNonce: number | undefined + appCommitment: string | undefined + }, + appAction: DecodedMetadataObject +): Promise { + // If one is missing we cannot verify the signature + if ( + !appAction.appId || + !appAction.signature || + typeof appAction.nonce !== 'number' || + !validationContext.appCommitment + ) { + invalidMetadata('Missing action fields to verify app') + return undefined + } + + const app = await getAppById(ctx.store, appAction.appId) + + if (!app || !app.authKey) { + invalidMetadata('No app of given id found') + return undefined + } + + if (typeof validationContext.ownerNonce === 'undefined' || validationContext.ownerNonce !== appAction.nonce) { + invalidMetadata('Invalid app action nonce') + + return undefined + } + + try { + const isSignatureValid = ed25519Verify( + validationContext.appCommitment, + appAction.signature as Uint8Array, + app.authKey + ) + + if (!isSignatureValid) { + invalidMetadata('Invalid app action signature') + } + + return isSignatureValid ? app : undefined + } catch (e) { + invalidMetadata((e as Error)?.message) + return undefined + } +} + +export async function processAppActionMetadata( + ctx: EventContext & StoreContext, + entity: T, + meta: DecodedMetadataObject, + validationContext: { + ownerNonce: number | undefined + appCommitment: string | undefined + }, + entityMetadataProcessor: (entity: T) => Promise +): Promise { + const app = await validateAndGetApp(ctx, validationContext, meta) + if (!app) { + return entityMetadataProcessor(entity) + } + + integrateMeta(entity, { entryApp: app }, ['entryApp']) + + return entityMetadataProcessor(entity) +} + export async function processChannelMetadata( ctx: EventContext & StoreContext, channel: Channel, @@ -688,3 +765,24 @@ export async function unsetAssetRelations(store: DatabaseManager, dataObject: St export function mapAgentPermission(permission: PalletContentIterableEnumsChannelActionPermission): string { return permission.toString() } + +export function generateAppActionCommitment( + nonce: number, + creatorId: string, + assets: Uint8Array, + rawAction?: Bytes, + rawAppActionMetadata?: Bytes +): string { + const rawCommitment = [ + nonce, + creatorId, + u8aToHex(assets), + ...(rawAction ? [u8aToHex(rawAction)] : []), + ...(rawAppActionMetadata ? [u8aToHex(rawAppActionMetadata)] : []), + ] + return stringToHex(JSON.stringify(rawCommitment)) +} + +export function u8aToBytes(array?: DecodedMetadataObject | null): Bytes { + return createType('Bytes', array ? u8aToHex(array as Uint8Array) : '') +} diff --git a/query-node/mappings/src/content/video.ts b/query-node/mappings/src/content/video.ts index 90956b9e87..dcf53eff7b 100644 --- a/query-node/mappings/src/content/video.ts +++ b/query-node/mappings/src/content/video.ts @@ -2,7 +2,7 @@ eslint-disable @typescript-eslint/naming-convention */ import { DatabaseManager, EventContext, StoreContext } from '@joystream/hydra-common' -import { ContentMetadata, IVideoMetadata } from '@joystream/metadata-protobuf' +import { AppAction, AppActionMetadata, ContentMetadata, IAppAction, IVideoMetadata } from '@joystream/metadata-protobuf' import { ChannelId, DataObjectId, VideoId } from '@joystream/types/primitives' import { PalletContentPermissionsContentActor as ContentActor, @@ -34,6 +34,7 @@ import { VideoDeletedEvent, VideoVisibilitySetByModeratorEvent, VideoSubtitle, + Membership, } from 'query-node/dist/model' import { Content } from '../../generated/types' import { bytesToString, deserializeMetadata, genericEventFields, inconsistentState, logger } from '../common' @@ -43,11 +44,15 @@ import { createNft } from './nft' import { convertContentActor, convertContentActorToChannelOrNftOwner, + generateAppActionCommitment, + processAppActionMetadata, processVideoMetadata, + u8aToBytes, unsetAssetRelations, videoRelationsForCounters, } from './utils' import { BTreeSet } from '@polkadot/types' +import { integrateMeta } from '@joystream/metadata-protobuf/utils' interface ContentCreatedEventData { contentActor: ContentActor @@ -93,19 +98,22 @@ export async function content_ContentCreated(ctx: EventContext & StoreContext): } // deserialize & process metadata - const contentMetadata = meta.isSome ? deserializeMetadata(ContentMetadata, meta.unwrap()) : undefined - + const appAction = meta.isSome ? deserializeMetadata(AppAction, meta.unwrap(), { skipWarning: true }) : undefined // Content Creation Preference // 1. metadata == `VideoMetadata` || undefined -> create Video // 1. metadata == `PlaylistMetadata` -> create Playlist (Not Supported Yet) - - await processCreateVideoMessage(ctx, channel, contentMetadata?.videoMetadata || undefined, contentCreatedEventData) + if (appAction) { + await processCreateVideoMessage(ctx, channel, appAction, contentCreatedEventData) + } else { + const contentMetadata = meta.isSome ? deserializeMetadata(ContentMetadata, meta.unwrap()) : undefined + await processCreateVideoMessage(ctx, channel, contentMetadata?.videoMetadata ?? undefined, contentCreatedEventData) + } } export async function processCreateVideoMessage( ctx: EventContext & StoreContext, channel: Channel, - metadata: DecodedMetadataObject | undefined, + metadata: DecodedMetadataObject | DecodedMetadataObject | undefined, contentCreatedEventData: ContentCreatedEventData ): Promise { const { store, event } = ctx @@ -123,12 +131,42 @@ export async function processCreateVideoMessage( reactionsCount: 0, }) - if (metadata) { - await processVideoMetadata(ctx, video, metadata, newDataObjectIds) + if (metadata && 'appId' in metadata) { + const contentMetadataBytes = u8aToBytes(metadata.rawAction) + const videoMetadata = deserializeMetadata(ContentMetadata, contentMetadataBytes)?.videoMetadata ?? {} + const appActionMetadataBytes = metadata.metadata ? u8aToBytes(metadata.metadata) : undefined + + const appCommitment = generateAppActionCommitment( + channel.ownerMember?.totalVideosCreated ?? -1, + channel.id ?? '', + contentCreationParameters.assets.toU8a(), + metadata.rawAction ? contentMetadataBytes : undefined, + appActionMetadataBytes + ) + await processAppActionMetadata( + ctx, + video, + metadata, + { ownerNonce: channel.ownerMember?.totalVideosCreated, appCommitment }, + (entity) => { + if ('entryApp' in entity && appActionMetadataBytes) { + const appActionMetadata = deserializeMetadata(AppActionMetadata, appActionMetadataBytes) + + appActionMetadata?.videoId && integrateMeta(entity, { ytVideoId: appActionMetadata.videoId }, ['ytVideoId']) + } + return processVideoMetadata(ctx, entity, videoMetadata, newDataObjectIds) + } + ) + } else if (metadata) { + await processVideoMetadata(ctx, video, metadata as DecodedMetadataObject, newDataObjectIds) } // save video await store.save