github.com/kaleido-io/firefly@v0.0.0-20210622132723-8b4b6aacb971/kat/src/handlers/asset-trade.ts (about)

     1  // Copyright © 2021 Kaleido, Inc.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { v4 as uuidV4 } from 'uuid';
    16  import Ajv from 'ajv';
    17  import { config } from '../lib/config';
    18  import { AssetTradeMessage, IApp2AppMessageHeader, IApp2AppMessageListener, IAssetTradePrivateAssetInstanceAuthorizationRequest, IAssetTradePrivateAssetInstancePush, IAssetTradePrivateAssetInstanceRequest, IAssetTradePrivateAssetInstanceResponse, IDBAssetDefinition, IDBAssetInstance, IDBMember, IDocExchangeListener, IDocExchangeTransferData } from "../lib/interfaces";
    19  import * as utils from '../lib/utils';
    20  import * as database from '../clients/database';
    21  import * as app2app from '../clients/app2app';
    22  import * as docExchange from '../clients/doc-exchange';
    23  import { pendingAssetInstancePrivateContentDeliveries } from './asset-instances';
    24  const log = utils.getLogger('handlers/asset-trade.ts');
    25  
    26  const ajv = new Ajv();
    27  
    28  export const assetTradeHandler = (headers: IApp2AppMessageHeader, content: AssetTradeMessage) => {
    29    if (content.type === 'private-asset-instance-request') {
    30      processPrivateAssetInstanceRequest(headers, content);
    31    } else if (content.type === 'private-asset-instance-push') {
    32      processPrivateAssetInstancePush(headers, content);
    33    }
    34  };
    35  
    36  const processPrivateAssetInstanceRequest = async (headers: IApp2AppMessageHeader, request: IAssetTradePrivateAssetInstanceRequest) => {
    37    let tradeResponse: IAssetTradePrivateAssetInstanceResponse = {
    38      type: "private-asset-instance-response",
    39      tradeID: request.tradeID,
    40      assetInstanceID: request.assetInstanceID
    41    };
    42    const requester = await database.retrieveMemberByAddress(request.requester.address);
    43    try {
    44      if (requester === null) {
    45        throw new Error(`Unknown requester ${request.requester.address}`);
    46      }
    47      if (requester.assetTrailInstanceID !== request.requester.assetTrailInstanceID) {
    48        throw new Error(`Requester asset trail instance mismatch. Expected ${requester.assetTrailInstanceID}, ` +
    49          `actual ${request.requester.assetTrailInstanceID}`);
    50      }
    51      if (requester.app2appDestination !== headers.from) {
    52        throw new Error(`Requester App2App destination mismatch. Expected ${requester.app2appDestination}, ` +
    53          `actual ${headers.from}`);
    54      }
    55      const assetInstance = await database.retrieveAssetInstanceByID(request.assetDefinitionID, request.assetInstanceID);
    56      if (assetInstance === null) {
    57        throw new Error(`Unknown asset instance ${request.assetInstanceID}`);
    58      }
    59      const author = await database.retrieveMemberByAddress(assetInstance.author);
    60      if (author === null) {
    61        throw new Error(`Unknown asset instance author`);
    62      }
    63      if (author.assetTrailInstanceID !== config.assetTrailInstanceID) {
    64        throw new Error(`Asset instance ${assetInstance.assetInstanceID} not authored`);
    65      }
    66      const assetDefinition = await database.retrieveAssetDefinitionByID(assetInstance.assetDefinitionID);
    67      if (assetDefinition === null) {
    68        throw new Error(`Unknown asset definition ${assetInstance.assetDefinitionID}`);
    69      }
    70      if (!assetDefinition.isContentPrivate) {
    71        throw new Error(`Asset instance ${assetInstance.assetInstanceID} not private`);
    72      }
    73      const authorized = await handlePrivateAssetInstanceAuthorization(assetInstance, requester, request.metadata);
    74      if (authorized !== true) {
    75        throw new Error('Access denied');
    76      }
    77      if (assetDefinition.contentSchema) {
    78        tradeResponse.content = assetInstance.content;
    79      } else {
    80        await docExchange.transfer(config.docExchange.destination, requester.docExchangeDestination,
    81          utils.getUnstructuredFilePathInDocExchange(request.assetInstanceID));
    82        tradeResponse.filename = assetInstance.filename;
    83        log.info(`Private asset instance trade request (instance=${assetInstance.assetDefinitionID}, requester=${request.requester.address}, tradeId=${request.tradeID}) successfully completed`);
    84      }
    85    } catch (err) {
    86      tradeResponse.rejection = err.message;
    87    } finally {
    88      app2app.dispatchMessage(headers.from, tradeResponse);
    89    }
    90  };
    91  
    92  const handlePrivateAssetInstanceAuthorization = (assetInstance: IDBAssetInstance, requester: IDBMember, metadata: object | undefined): Promise<boolean> => {
    93    return new Promise((resolve, reject) => {
    94      const authorizationID = uuidV4();
    95      const authorizationRequest: IAssetTradePrivateAssetInstanceAuthorizationRequest = {
    96        type: 'private-asset-instance-authorization-request',
    97        authorizationID,
    98        assetInstance,
    99        requester,
   100        metadata
   101      };
   102      const timeout = setTimeout(() => {
   103        app2app.removeListener(app2appListener);
   104        reject(new Error('Asset instance authorization response timed out'));
   105      }, utils.constants.TRADE_AUTHORIZATION_TIMEOUT_SECONDS * 1000);
   106      const app2appListener: IApp2AppMessageListener = (headers: IApp2AppMessageHeader, content: AssetTradeMessage) => {
   107        if (headers.from === config.app2app.destinations.client && content.type === 'private-asset-instance-authorization-response' &&
   108          content.authorizationID === authorizationID) {
   109          clearTimeout(timeout);
   110          app2app.removeListener(app2appListener);
   111          resolve(content.authorized);
   112        }
   113      };
   114      app2app.addListener(app2appListener);
   115      app2app.dispatchMessage(config.app2app.destinations.client, authorizationRequest);
   116    });
   117  };
   118  
   119  export const coordinateAssetTrade = async (assetInstance: IDBAssetInstance, assetDefinition: IDBAssetDefinition,
   120    requesterAddress: string, metadata: object | undefined, authorDestination: string) => {
   121    const tradeID = uuidV4();
   122    const tradeRequest: IAssetTradePrivateAssetInstanceRequest = {
   123      type: 'private-asset-instance-request',
   124      tradeID,
   125      assetInstanceID: assetInstance.assetInstanceID,
   126      assetDefinitionID: assetInstance.assetDefinitionID,
   127      requester: {
   128        assetTrailInstanceID: config.assetTrailInstanceID,
   129        address: requesterAddress
   130      },
   131      metadata
   132    };
   133    const docExchangePromise = assetDefinition.contentSchema === undefined ? getDocumentExchangePromise(assetInstance.assetInstanceID) : Promise.resolve();
   134    const app2appPromise: Promise<void> = new Promise((resolve, reject) => {
   135      const timeout = setTimeout(() => {
   136        app2app.removeListener(app2appListener);
   137        reject(new Error('Asset instance author response timed out'));
   138      }, utils.constants.ASSET_INSTANCE_TRADE_TIMEOUT_SECONDS * 1000);
   139      const app2appListener: IApp2AppMessageListener = (_headers: IApp2AppMessageHeader, content: AssetTradeMessage) => {
   140        if (content.type === 'private-asset-instance-response' && content.tradeID === tradeID) {
   141          clearTimeout(timeout);
   142          app2app.removeListener(app2appListener);
   143          if (content.rejection) {
   144            reject(new Error(`Asset instance request rejected. ${content.rejection}`));
   145          } else {
   146            const contentHash = `0x${utils.getSha256(JSON.stringify(content.content))}`;
   147            if (contentHash !== assetInstance.contentHash) {
   148              reject(new Error('Asset instance content hash mismatch'));
   149            } else if (assetDefinition.contentSchema && !ajv.validate(assetDefinition.contentSchema, content.content)) {
   150              reject(new Error('Asset instance content does not conform to schema'));
   151            } else {
   152              database.setAssetInstancePrivateContent(assetInstance.assetDefinitionID, content.assetInstanceID, content.content, content.filename);
   153              resolve();
   154            }
   155          }
   156        }
   157      };
   158      app2app.addListener(app2appListener);
   159      app2app.dispatchMessage(authorDestination, tradeRequest);
   160    });
   161    await Promise.all([app2appPromise, docExchangePromise]);
   162  };
   163  
   164  const getDocumentExchangePromise = (assetInstanceID: string): Promise<void> => {
   165    return new Promise((resolve, reject) => {
   166      const timeout = setTimeout(() => {
   167        docExchange.removeListener(docExchangeListener);
   168        reject(new Error('Off-chain asset transfer timeout'));
   169      }, utils.constants.DOCUMENT_EXCHANGE_TRANSFER_TIMEOUT_SECONDS * 1000);
   170      const docExchangeListener: IDocExchangeListener = (event: IDocExchangeTransferData) => {
   171        if (event.document === utils.getUnstructuredFilePathInDocExchange(assetInstanceID)) {
   172          clearTimeout(timeout);
   173          docExchange.removeListener(docExchangeListener);
   174          resolve();
   175        }
   176      };
   177      docExchange.addListener(docExchangeListener);
   178    });
   179  };
   180  
   181  const processPrivateAssetInstancePush = async (headers: IApp2AppMessageHeader, push: IAssetTradePrivateAssetInstancePush) => {
   182    log.trace(`Handling private asset instance push event (instance=${push.assetInstanceID}, filename=${push.filename})`);
   183    const assetInstance = await database.retrieveAssetInstanceByID(push.assetDefinitionID, push.assetInstanceID);
   184    if (assetInstance !== null) {
   185      log.trace(`Found existing asset instance, ${JSON.stringify(assetInstance, null, 2)}`);
   186      const author = await database.retrieveMemberByAddress(assetInstance.author);
   187      if (author === null) {
   188        throw new Error(`Unknown author for asset ${assetInstance.assetInstanceID}`);
   189      }
   190      if (author.app2appDestination !== headers.from) {
   191        throw new Error(`Asset instance author destination mismatch ${author.app2appDestination} - ${headers.from}`);
   192      }
   193      if (push.content) {
   194        const contentHash = `0x${utils.getSha256(JSON.stringify(push.content))}`;
   195        if (assetInstance.contentHash !== contentHash) {
   196          throw new Error('Private asset content hash mismatch');
   197        }
   198      }
   199      await database.setAssetInstancePrivateContent(push.assetDefinitionID, push.assetInstanceID, push.content, push.filename);
   200      log.info(`Private asset instance from push event (instance=${push.assetInstanceID}, filename=${push.filename}) saved in local database`);
   201    } else {
   202      log.info(`Private asset instance ${push.assetDefinitionID}/${push.assetInstanceID} from push event not found in local database, adding to pending instances`);
   203      pendingAssetInstancePrivateContentDeliveries[push.assetInstanceID] = { ...push, fromDestination: headers.from };
   204    }
   205  }