Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Crypto transaction validations for creations #335

Merged
merged 11 commits into from
Mar 11, 2023
Merged
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
12 changes: 11 additions & 1 deletion app/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,14 @@ WEB_CLIENT_BASE_URL=https://pocre.netlify.app # the base url of web app
# pinata config (for ipfs storage)
PINATA_JWT_SECRET=api_key_jwt_secret_provided_by_pinata
PINATA_API_JSON_PIN_URL=pinata_api_url_to_pin_json
PINATA_API_UNPIN_URL=pinata_api_url_to_unpin_data
PINATA_API_UNPIN_URL=pinata_api_url_to_unpin_data

# crypto transactions config
MIN_BLOCK_CONFIRMATIONS_FOR_VALID_CRYPTO_TRANSACTION=the_min_amount_of_validated_blocks_after_which_we_assume_transaction_is_valid

# blockfrost config (used for validating crypto transactions)
BLOCKFROST_PROJECT_ID=project_id_from_blockfrost
BLOCKFROST_API_BASE_URL=https://cardano-preview.blockfrost.io/api/v0
BLOCKFROST_API_TRANSACTIONS_ENDPOINT=txs
BLOCKFROST_API_BLOCKS_ENDPOINT=blocks
BLOCKFROST_WEBHOOK_AUTH_TOKEN=webhook_token_from_blockfrost
1 change: 1 addition & 0 deletions app/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@babel/runtime": "^7.14.6",
"@blockfrost/blockfrost-js": "^5.2.0",
"@sendgrid/mail": "^7.7.0",
"@types/joi": "^17.2.3",
"@types/jsonwebtoken": "^8.5.9",
Expand Down
24 changes: 24 additions & 0 deletions app/api/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ const envVarsSchema = Joi.object()
PINATA_JWT_SECRET: Joi.string().description('pinata jwt secret used to store data on ipfs').required(),
PINATA_API_JSON_PIN_URL: Joi.string().description('pinata api url to pin json data').required(),
PINATA_API_UNPIN_URL: Joi.string().description('pinata api url to unpin data').required(),
BLOCKFROST_PROJECT_ID: Joi.string().description('blockfrost project id to interact with their service').required(),
BLOCKFROST_API_BASE_URL: Joi.string().description('blockfrost base url to make http calls').required(),
BLOCKFROST_API_TRANSACTIONS_ENDPOINT: Joi.string().description('blockfrost endpoint to query transactions').required(),
BLOCKFROST_API_BLOCKS_ENDPOINT: Joi.string().description('blockfrost endpoint to query blocks').required(),
BLOCKFROST_WEBHOOK_AUTH_TOKEN: Joi.string()
.description('blockfrost webhook token to verify webhooks signature')
.required(),
MIN_BLOCK_CONFIRMATIONS_FOR_VALID_CRYPTO_TRANSACTION: Joi.string()
.description('the minimun block confirmation threshold after which we consider a transaction successful')
.required(),
})
.unknown();

Expand Down Expand Up @@ -93,4 +103,18 @@ export default {
unpin: envVars.PINATA_API_UNPIN_URL,
},
},
crypto: {
valid_transaction: {
min_block_confirmations: envVars.MIN_BLOCK_CONFIRMATIONS_FOR_VALID_CRYPTO_TRANSACTION,
},
},
blockfrost: {
project_id: envVars.BLOCKFROST_PROJECT_ID,
base_api_url: envVars.BLOCKFROST_API_BASE_URL,
endpoints: {
transactions: envVars.BLOCKFROST_API_TRANSACTIONS_ENDPOINT,
blocks: envVars.BLOCKFROST_API_BLOCKS_ENDPOINT,
},
webhook_token: envVars.BLOCKFROST_WEBHOOK_AUTH_TOKEN,
},
};
8 changes: 8 additions & 0 deletions app/api/src/constants/transactionPurposes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default Object.freeze({
PUBLISH_CREATION: 'publish_creation',
FINALIZE_CREATION: 'finalize_creation',
START_LITIGATION: 'start_litigation',
CAST_LITIGATION_VOTE: 'cast_litigation_vote',
REDEEM_LITIGATED_ITEM: 'redeem_litigated_item',
ACCEPT_RECOGNITION: 'accept_recognition',
});
215 changes: 120 additions & 95 deletions app/api/src/controllers/creation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import config from '../config/config';
import publishPlatforms from '../constants/publishPlatforms';
import reputationStarTimeWindows from '../constants/reputationStarTimeWindows';
import statusTypes from '../constants/statusTypes';
import transactionPurposes from '../constants/transactionPurposes';
import * as creationService from '../services/creation.service';
import * as litigationService from '../services/litigation.service';
import { getMaterialById, updateMaterialById } from '../services/material.service';
import { createRecognition } from '../services/recognition.service';
import { getTagById } from '../services/tag.service';
import { getTransactionById } from '../services/transaction.service';
import { getUserByCriteria, IUserDoc } from '../services/user.service';
import ApiError from '../utils/ApiError';
import catchAsync from '../utils/catchAsync';
Expand Down Expand Up @@ -98,45 +100,10 @@ export const createCreation = catchAsync(async (req, res): Promise<void> => {
)
.toISOString(),
is_fully_owned: false,
is_draft: true,
is_claimable: true,
});

// send recognitions to material authors if the creation is published
if (!req.body.is_draft && req.body.materials && req.body.materials.length > 0) {
// get all materials
// eslint-disable-next-line @typescript-eslint/return-await
const materials = await Promise.all(req.body.materials.map(async (id: string) => await getMaterialById(id)));

await Promise.all(
materials.map(async (m: any) => {
const foundAuthor = await getUserByCriteria('user_id', m.author_id, true);

// send invitation emails to new authors
if (foundAuthor && foundAuthor.is_invited && foundAuthor.email_address) {
await sendMail({
to: foundAuthor?.email_address as string,
subject: `Invitation to recognize authorship of "${m.material_title}"`,
message: `You were recognized as author of "${m.material_title}" by ${
(req.user as IUserDoc)?.user_name
}. Please signup on ${config.web_client_base_url}/signup?token=${encode(
foundAuthor.user_id
)} to be recognized as the author.`,
}).catch(() => null);
}

// send recognition
const recognition = await createRecognition({
recognition_for: m.author_id,
recognition_by: (req.user as IUserDoc).user_id,
status: 'pending',
status_updated: new Date().toISOString(),
});

// update material with recognition
await updateMaterialById(m.material_id, { recognition_id: recognition.recognition_id }, { owner_id: m.author_id });
})
);
}

res.send(newCreation);
});

Expand Down Expand Up @@ -215,78 +182,75 @@ export const updateCreationById = catchAsync(async (req, res): Promise<void> =>
}
);

// send recognitions to material authors if the creation is published
if (foundCreation?.is_draft && req.body.is_draft === false && updatedCreation && updatedCreation.materials.length > 0) {
// get all materials
// eslint-disable-next-line @typescript-eslint/return-await
const materials = await Promise.all(updatedCreation.materials.map(async (id: string) => await getMaterialById(id)));

await Promise.all(
materials.map(async (m: any) => {
const foundAuthor = await getUserByCriteria('user_id', m.author_id, true);

// send invitation emails to new authors
if (foundAuthor && foundAuthor.is_invited && foundAuthor.email_address) {
await sendMail({
to: foundAuthor?.email_address as string,
subject: `Invitation to recognize authorship of "${m.material_title}"`,
message: `You were recognized as author of "${m.material_title}" by ${
(req.user as IUserDoc)?.user_name
}. Please signup on ${config.web_client_base_url}/signup?token=${encode(
foundAuthor.user_id
)} to be recognized as the author.`,
}).catch(() => null);
}

// send recognition
const recognition = await createRecognition({
recognition_for: m.author_id,
recognition_by: (req.user as IUserDoc).user_id,
status: 'pending',
status_updated: new Date().toISOString(),
});

// update material with recognition
await updateMaterialById(m.material_id, {
recognition_id: recognition.recognition_id,
});
})
);
}

res.send(updatedCreation);
});

export const publishCreation = catchAsync(async (req, res): Promise<void> => {
// get original creation
const foundCreation = await creationService.getCreationById(req.params.creation_id);
const reqUser = req.user as IUserDoc;

// block publishing if creation is draft
if (foundCreation?.is_draft) {
throw new ApiError(httpStatus.NOT_ACCEPTABLE, `draft creation cannot be published`);
}
// get original creation
const foundCreation = await creationService.getCreationById(req.params.creation_id, {
owner_id: reqUser.user_id,
});

const updateBody = await (async () => {
if (req.body.publish_on === publishPlatforms.BLOCKCHAIN) {
// block owning if caw has not passed
if (foundCreation && moment().isBefore(moment(new Date(foundCreation?.creation_authorship_window)))) {
throw new ApiError(httpStatus.NOT_ACCEPTABLE, `finalization only allowed after creation authorship window`);
}

return { is_onchain: true, is_fully_owned: true };
}

// remove from draft and upload to ipfs (assuming the 'publish_creation' transaction is now validated)
if (req.body.publish_on === publishPlatforms.IPFS) {
// remove extra keys from creation json
const jsonForIPFS: any = foundCreation;
const tempUpdateBody = { is_draft: false };

// prepare creation json
const jsonForIPFS: any = { ...foundCreation, ...tempUpdateBody };
delete jsonForIPFS.ipfs_hash;

// store on ipfs
const hash = await pinJSON(jsonForIPFS).catch(() => null);

if (!hash) throw new ApiError(httpStatus.INTERNAL_SERVER_ERROR, `failed to upload creation to ipfs`);

return { ipfs_hash: hash };
// send recognitions to material authors since the creation is now published
if (foundCreation && foundCreation.materials.length > 0) {
// get all materials
// eslint-disable-next-line @typescript-eslint/return-await
const materials = await Promise.all(foundCreation.materials.map(async (id: string) => await getMaterialById(id)));

await Promise.all(
materials.map(async (m: any) => {
const foundAuthor = await getUserByCriteria('user_id', m.author_id, true);

// send invitation emails to new authors
if (foundAuthor && foundAuthor.is_invited && foundAuthor.email_address) {
await sendMail({
to: foundAuthor?.email_address as string,
subject: `Invitation to recognize authorship of "${m.material_title}"`,
message: `You were recognized as author of "${m.material_title}" by ${
(req.user as IUserDoc)?.user_name
}. Please signup on ${config.web_client_base_url}/signup?token=${encode(
foundAuthor.user_id
)} to be recognized as the author.`,
}).catch(() => null);
}

// send recognition
const recognition = await createRecognition({
recognition_for: m.author_id,
recognition_by: (req.user as IUserDoc).user_id,
status: 'pending',
status_updated: new Date().toISOString(),
});

// update material with recognition
await updateMaterialById(m.material_id, {
recognition_id: recognition.recognition_id,
});
})
);
}

return { ipfs_hash: hash, ...tempUpdateBody };
}

// set fully owned status (assuming the 'finalize_creation' transaction is now validated)
if (req.body.publish_on === publishPlatforms.BLOCKCHAIN) {
return { is_fully_owned: true };
}
})();

Expand All @@ -301,3 +265,64 @@ export const publishCreation = catchAsync(async (req, res): Promise<void> => {

res.send(updatedCreation);
});

export const registerCreationTransaction = catchAsync(async (req, res): Promise<void> => {
const reqUser = req.user as IUserDoc;

// verify transaction, will throw an error if transaction is not found
const foundTransaction = await getTransactionById(req.body.transaction_id, {
owner_id: reqUser.user_id,
});

// verify creation, will throw an error if creation is not found
const foundCreation = await creationService.getCreationById(req.params.creation_id, {
populate: ['transactions'],
owner_id: reqUser.user_id,
});

// check if transaction has correct purposes
if (
foundTransaction &&
!(
foundTransaction.transaction_purpose === transactionPurposes.FINALIZE_CREATION ||
foundTransaction.transaction_purpose === transactionPurposes.PUBLISH_CREATION
)
) {
throw new ApiError(httpStatus.NOT_ACCEPTABLE, `invalid transaction purpose for creation`);
}

// check if caw has passed if transaction purpose is to finalize creation
if (
foundTransaction &&
foundTransaction.transaction_purpose === transactionPurposes.FINALIZE_CREATION &&
foundCreation &&
moment().isBefore(moment(new Date(foundCreation.creation_authorship_window)))
) {
throw new ApiError(httpStatus.NOT_ACCEPTABLE, `finalization only allowed after creation authorship window`);
}

// check if original creation already has this transaction
if (
foundCreation &&
foundCreation.transactions &&
foundTransaction &&
foundCreation.transactions.find(
(x: any) =>
x.transaction_id === foundTransaction.transaction_id ||
x.transaction_purpose === foundTransaction.transaction_purpose
)
) {
throw new ApiError(httpStatus.NOT_ACCEPTABLE, `transaction already registered for creation`);
}

// update creation
const updatedCreation = await creationService.updateCreationById(
req.params.creation_id,
{ transactions: [...(foundCreation?.transactions || []).map((x: any) => x.transaction_id), req.body.transaction_id] },
{
owner_id: (req.user as IUserDoc).user_id,
}
);

res.send(updatedCreation);
});
11 changes: 11 additions & 0 deletions app/api/src/controllers/transaction.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as transactionService from '../services/transaction.service';
import { IUserDoc } from '../services/user.service';
import catchAsync from '../utils/catchAsync';

export const createTransaction = catchAsync(async (req, res): Promise<void> => {
const newTransaction = await transactionService.createTransaction({
...req.body,
maker_id: (req.user as IUserDoc).user_id,
});
res.send(newTransaction);
});
Loading