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

TW-1240: Magic Square campaign #139

Merged
merged 4 commits into from
Jan 17, 2024
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
9 changes: 9 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Editor configuration, see http://editorconfig.org
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
6 changes: 3 additions & 3 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"no-shadow": "off",
"import/no-duplicates": "error",
"import/no-self-import": "error",
"import/no-cycle": "error",
"import/order": [
"error",
{
Expand All @@ -36,12 +37,11 @@
"newlines-between": "always"
}
],
"@typescript-eslint/strict-boolean-expressions": "error",
"@typescript-eslint/strict-boolean-expressions": "warn",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/ban-ts-comment": "off",
"quotes": ["error", "single"]
"@typescript-eslint/ban-ts-comment": "off"
},
"globals": {
"localStorage": true
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
"author": "Inokentii Mazhara <[email protected]>",
"license": "MIT",
"dependencies": {
"@ethersproject/address": "^5.7.0",
"@ethersproject/hash": "^5.7.0",
"@ethersproject/strings": "^5.7.0",
"@ethersproject/transactions": "^5.7.0",
"@stablelib/random": "^1.0.2",
"@taquito/rpc": "14.0.0",
"@taquito/taquito": "14.0.0",
"@taquito/tzip12": "14.0.0",
Expand All @@ -18,6 +23,7 @@
"dotenv": "^9.0.2",
"express": "^4.18.2",
"firebase-admin": "^10.0.2",
"http-status-codes": "^2.3.0",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"memoizee": "^0.4.15",
Expand All @@ -37,6 +43,7 @@
"ts": "tsc --pretty",
"lint": "eslint ./src --ext .js,.ts",
"lint:fix": "eslint ./src --ext .js,.ts --fix",
"lint:rules": "eslint --print-config",
"clean": "rimraf dist/",
"db-migration": "cd migrations/notifications && npx ts-node index.ts"
},
Expand Down
27 changes: 1 addition & 26 deletions src/advertising/slise.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { redisClient } from '../redis';
import { isDefined } from '../utils/helpers';
import { objectStorageMethodsFactory, redisClient } from '../redis';

/** Style properties names that are likely to be unnecessary for banners are skipped */
export const stylePropsNames = [
Expand Down Expand Up @@ -100,30 +99,6 @@ const SLISE_AD_PROVIDERS_BY_SITES_KEY = 'slise_ad_providers_by_sites';
const SLISE_AD_PROVIDERS_ALL_SITES_KEY = 'slise_ad_providers_all_sites';
const SLISE_AD_PROVIDERS_LIST_KEY = 'slise_ad_providers_list';

const objectStorageMethodsFactory = <V>(storageKey: string, fallbackValue: V) => ({
getByKey: async (key: string): Promise<V> => {
const value = await redisClient.hget(storageKey, key);

return isDefined(value) ? JSON.parse(value) : fallbackValue;
},
getAllValues: async (): Promise<Record<string, V>> => {
const values = await redisClient.hgetall(storageKey);

const parsedValues: Record<string, V> = {};
for (const key in values) {
parsedValues[key] = JSON.parse(values[key]);
}

return parsedValues;
},
upsertValues: (newValues: Record<string, V>) =>
redisClient.hmset(
storageKey,
Object.fromEntries(Object.entries(newValues).map(([domain, value]) => [domain, JSON.stringify(value)]))
),
removeValues: (keys: string[]) => redisClient.hdel(storageKey, ...keys)
});

export const {
getByKey: getSliseAdPlacesRulesByDomain,
getAllValues: getAllSliseAdPlacesRules,
Expand Down
50 changes: 50 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { getAdvertisingInfo } from './advertising/advertising';
import { MIN_ANDROID_APP_VERSION, MIN_IOS_APP_VERSION } from './config';
import getDAppsStats from './getDAppsStats';
import { getMagicSquareQuestParticipants, startMagicSquareQuest } from './magic-square';
import { basicAuth } from './middlewares/basic-auth.middleware';
import { Notification, PlatformType } from './notifications/notification.interface';
import { getImageFallback } from './notifications/utils/get-image-fallback.util';
Expand All @@ -28,10 +29,12 @@
import { getAliceBobOrderInfo } from './utils/alice-bob/get-alice-bob-order-info';
import { getAliceBobPairInfo } from './utils/alice-bob/get-alice-bob-pair-info';
import { getAliceBobPairsInfo } from './utils/alice-bob/get-alice-bob-pairs-info';
import { CodedError } from './utils/errors';
import { coinGeckoTokens } from './utils/gecko-tokens';
import { getExternalApiErrorPayload, isDefined, isNonEmptyString } from './utils/helpers';
import logger from './utils/logger';
import { getSignedMoonPayUrl } from './utils/moonpay/get-signed-moonpay-url';
import { getSigningNonce } from './utils/signing-nonce';
import SingleQueryDataProvider from './utils/SingleQueryDataProvider';
import { tezExchangeRateProvider } from './utils/tezos';
import { getExchangeRatesFromDB } from './utils/tokens';
Expand Down Expand Up @@ -154,7 +157,7 @@
await redisClient.lpush('notifications', JSON.stringify(newNotification));

res.status(200).send({ message: 'Notification added successfully', notification: newNotification });
} catch (error: any) {

Check warning on line 160 in src/index.ts

View workflow job for this annotation

GitHub Actions / Checks if ts and lint works

Unexpected any. Specify a different type
res.status(500).send({ error: error.message });
}
});
Expand Down Expand Up @@ -327,6 +330,53 @@

app.use('/api/slise-ad-rules', sliseRulesRouter);

app.post('/api/magic-square-quest/start', async (req, res) => {
keshan3262 marked this conversation as resolved.
Show resolved Hide resolved
try {
await startMagicSquareQuest(req.body);

res.status(200).send({ message: 'Quest successfully started' });
} catch (error: any) {

Check warning on line 338 in src/index.ts

View workflow job for this annotation

GitHub Actions / Checks if ts and lint works

Unexpected any. Specify a different type
console.error(error);

if (error instanceof CodedError) {
res.status(error.code).send(error.buildResponse());
} else {
res.status(500).send({ message: error?.message });
}
}
});

app.get('/api/magic-square-quest/participants', basicAuth, async (req, res) => {
try {
res.status(200).send(await getMagicSquareQuestParticipants());
} catch (error: any) {

Check warning on line 352 in src/index.ts

View workflow job for this annotation

GitHub Actions / Checks if ts and lint works

Unexpected any. Specify a different type
console.error(error);

if (error instanceof CodedError) {
res.status(error.code).send(error.buildResponse());
} else {
res.status(500).send({ message: error?.message });
}
}
});

app.get('/api/signing-nonce', (req, res) => {
try {
const pkh = req.query.pkh;
if (!pkh || typeof pkh !== 'string') throw new Error('PKH is not a string');

Check warning on line 366 in src/index.ts

View workflow job for this annotation

GitHub Actions / Checks if ts and lint works

Unexpected value in conditional. A boolean expression is required

res.status(200).send(getSigningNonce(pkh));
} catch (error: any) {

Check warning on line 369 in src/index.ts

View workflow job for this annotation

GitHub Actions / Checks if ts and lint works

Unexpected any. Specify a different type
console.error(error);

if (error instanceof CodedError) {
lourenc marked this conversation as resolved.
Show resolved Hide resolved
res.status(error.code).send(error.buildResponse());
} else {
res.status(500).send({ message: error?.message });
}
}
});

const swaggerOptions = {
swaggerDefinition: {
openapi: '3.0.0',
Expand Down
104 changes: 104 additions & 0 deletions src/magic-square.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as ethersAddressUtils from '@ethersproject/address';
import * as ethersHashUtils from '@ethersproject/hash';
import * as ethersStringsUtils from '@ethersproject/strings';
import * as ethersTxUtils from '@ethersproject/transactions';
import { verifySignature, getPkhfromPk } from '@taquito/utils';
import { StatusCodes } from 'http-status-codes';

import { objectStorageMethodsFactory } from './redis';
import { CodedError } from './utils/errors';
import { safeCheck } from './utils/helpers';
import { getSigningNonce, removeSigningNonce } from './utils/signing-nonce';

interface Participant {
pkh: string;
evmPkh: string;
ts: string;
}

const REDIS_DB_KEY = 'magic_square_quest';

const redisStorage = objectStorageMethodsFactory<Participant, null>(REDIS_DB_KEY, null);

export function getMagicSquareQuestParticipants() {
return redisStorage.getAllValues().then(records => Object.values(records));
}

interface StartQuestPayload {
publicKey: string;
messageBytes: string;
signature: string;
evm: {
pkh: string;
messageBytes: string;
signature: string;
};
}

export async function startMagicSquareQuest({ publicKey, messageBytes, signature, evm }: StartQuestPayload) {
// Public Key Hashes

let pkh: string;
try {
pkh = getPkhfromPk(publicKey);
} catch (err) {
console.error(err);
throw new CodedError(StatusCodes.BAD_REQUEST, 'Invalid Tezos public key');
}

let evmPkh: string;
try {
// Corrects lower-cased addresses. Throws if invalid.
evmPkh = ethersAddressUtils.getAddress(evm.pkh);
} catch (err) {
console.error(err);
throw new CodedError(StatusCodes.BAD_REQUEST, 'Invalid EVM public key hash');
}

// Nonce
const { value: nonce } = getSigningNonce(pkh);
const nonceBytes = Buffer.from(nonce, 'utf-8').toString('hex');

if (!messageBytes.includes(nonceBytes))
throw new CodedError(StatusCodes.UNAUTHORIZED, 'Invalid Tezos message nonce', 'INVALID_NONCE_TEZ');

if (!evm.messageBytes.includes(nonceBytes))
throw new CodedError(StatusCodes.UNAUTHORIZED, 'Invalid EVM message nonce', 'INVALID_NONCE_EVM');

// Signatures

if (!safeCheck(() => verifySignature(messageBytes, publicKey, signature)))
throw new CodedError(StatusCodes.UNAUTHORIZED, 'Invalid Tezos signature or message');

if (
!safeCheck(() => {
const messageBytes = ethersStringsUtils.toUtf8String(evm.messageBytes);
const messageHash = ethersHashUtils.hashMessage(messageBytes);

return ethersTxUtils.recoverAddress(messageHash, evm.signature) === evmPkh;
})
)
throw new CodedError(StatusCodes.UNAUTHORIZED, 'Invalid EVM signature or message');

// Presence check

const storageKey = `${pkh}+${evmPkh}`;

const existingValue = await redisStorage.getByKey(storageKey);

if (existingValue)
throw new CodedError(StatusCodes.CONFLICT, 'Your quest was already started before', 'QUEST_IS_STARTED');

// Invalidating nonce
removeSigningNonce(pkh);

// Registering

const participant: Participant = {
pkh,
evmPkh,
ts: new Date().toISOString()
};

await redisStorage.upsertValues({ [storageKey]: participant });
}
25 changes: 25 additions & 0 deletions src/redis.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
import { Redis } from 'ioredis';

import { EnvVars } from './config';
import { isDefined } from './utils/helpers';
import logger from './utils/logger';

export const redisClient = new Redis(EnvVars.REDIS_URL);
redisClient.on('error', err => logger.error(err));

export const objectStorageMethodsFactory = <V, F = V>(storageKey: string, fallbackValue: F) => ({
getByKey: async (key: string): Promise<V | F> => {
const value = await redisClient.hget(storageKey, key);

return isDefined(value) ? JSON.parse(value) : fallbackValue;
},
getAllValues: async (): Promise<Record<string, V>> => {
const values = await redisClient.hgetall(storageKey);

const parsedValues: Record<string, V> = {};
for (const key in values) {
parsedValues[key] = JSON.parse(values[key]);
}

return parsedValues;
},
upsertValues: (newValues: Record<string, V>) =>
redisClient.hmset(
storageKey,
Object.fromEntries(Object.entries(newValues).map(([domain, value]) => [domain, JSON.stringify(value)]))
),
removeValues: (keys: string[]) => redisClient.hdel(storageKey, ...keys)
});
21 changes: 21 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { StatusCodes } from 'http-status-codes';

interface CodedErrorForResponse {
message: string;
code?: string;
}

type StatusCodeNumber = (typeof StatusCodes)[keyof typeof StatusCodes];

export class CodedError extends Error {
constructor(public code: StatusCodeNumber, message: string, public errorCode?: string) {
super(message);
}

buildResponse() {
const res: CodedErrorForResponse = { message: this.message };
if (this.errorCode) res.code = this.errorCode;

Check warning on line 17 in src/utils/errors.ts

View workflow job for this annotation

GitHub Actions / Checks if ts and lint works

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly

return res;
}
}
10 changes: 10 additions & 0 deletions src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,13 @@ export const getExternalApiErrorPayload = (error: unknown) => {

return { status, data };
};

export function safeCheck(check: () => boolean, def = false) {
try {
return check();
} catch (error) {
console.error();

return def;
}
}
32 changes: 32 additions & 0 deletions src/utils/signing-nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { randomStringForEntropy } from '@stablelib/random';
import { validateAddress, ValidationResult } from '@taquito/utils';
import memoizee from 'memoizee';

import { CodedError } from './errors';

const SIGNING_NONCE_TTL = 5 * 60_000;

export const getSigningNonce = memoizee(
(pkh: string) => {
if (validateAddress(pkh) !== ValidationResult.VALID) throw new CodedError(400, 'Invalid address');

return buildNonce();
},
{
max: 1_000_000,
maxAge: SIGNING_NONCE_TTL
}
);

export function removeSigningNonce(pkh: string) {
getSigningNonce.delete(pkh);
}

function buildNonce() {
// Same as in in SIWE.generateNonce()
const value = randomStringForEntropy(96);

const expiresAt = new Date(Date.now() + SIGNING_NONCE_TTL).toISOString();

return { value, expiresAt };
}
Loading
Loading