diff --git a/lib/api/Bouncer.ts b/lib/api/Bouncer.ts index 9c7eda05..317ea54e 100644 --- a/lib/api/Bouncer.ts +++ b/lib/api/Bouncer.ts @@ -22,7 +22,7 @@ class Bouncer { referral, ts, req.method, - req.path, + req.originalUrl, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore req.rawBody, diff --git a/lib/api/Controller.ts b/lib/api/Controller.ts index 8581dc6a..bf8811bb 100644 --- a/lib/api/Controller.ts +++ b/lib/api/Controller.ts @@ -534,7 +534,7 @@ class Controller { ): Promise => { try { const referral = await Bouncer.validateRequestAuthentication(req); - const stats = await ReferralStats.generate(referral.id); + const stats = await ReferralStats.getReferralFees(referral.id); successResponse(res, stats); } catch (error) { diff --git a/lib/api/Utils.ts b/lib/api/Utils.ts index ffe394ec..4a8d63e4 100644 --- a/lib/api/Utils.ts +++ b/lib/api/Utils.ts @@ -58,41 +58,26 @@ export const errorResponse = ( res: Response, error: unknown, statusCode = 400, - urlPrefix: string = '', ): void => { if (typeof error === 'string') { - writeErrorResponse(logger, req, res, statusCode, { error }, urlPrefix); + writeErrorResponse(logger, req, res, statusCode, { error }); } else { const errorObject = error as any; // Bitcoin Core related errors if (errorObject.details) { - writeErrorResponse( - logger, - req, - res, - statusCode, - { - error: errorObject.details, - }, - urlPrefix, - ); + writeErrorResponse(logger, req, res, statusCode, { + error: errorObject.details, + }); // Custom error when broadcasting a refund transaction fails because // the locktime requirement has not been met yet } else if (errorObject.timeoutBlockHeight) { - writeErrorResponse(logger, req, res, statusCode, error, urlPrefix); + writeErrorResponse(logger, req, res, statusCode, error); // Everything else } else { - writeErrorResponse( - logger, - req, - res, - statusCode, - { - error: errorObject.message, - }, - urlPrefix, - ); + writeErrorResponse(logger, req, res, statusCode, { + error: errorObject.message, + }); } } }; @@ -120,11 +105,10 @@ export const writeErrorResponse = ( res: Response, statusCode: number, error: any, - urlPrefix: string = '', ) => { if (!errorsNotToLog.includes(error?.error || error)) { logger.warn( - `Request ${req.method} ${urlPrefix + req.url} ${ + `Request ${req.method} ${req.originalUrl} ${ req.body && Object.keys(req.body).length > 0 ? `${JSON.stringify(req.body)} ` : '' diff --git a/lib/api/v2/ApiV2.ts b/lib/api/v2/ApiV2.ts index 67bfc236..4fb176d7 100644 --- a/lib/api/v2/ApiV2.ts +++ b/lib/api/v2/ApiV2.ts @@ -6,6 +6,7 @@ import { apiPrefix } from './Consts'; import ChainRouter from './routers/ChainRouter'; import InfoRouter from './routers/InfoRouter'; import NodesRouter from './routers/NodesRouter'; +import ReferralRouter from './routers/ReferralRouter'; import RouterBase from './routers/RouterBase'; import SwapRouter from './routers/SwapRouter'; @@ -22,6 +23,7 @@ class ApiV2 { new SwapRouter(this.logger, service, controller), new ChainRouter(this.logger, service), new NodesRouter(this.logger, service), + new ReferralRouter(this.logger), ]; } diff --git a/lib/api/v2/routers/ReferralRouter.ts b/lib/api/v2/routers/ReferralRouter.ts new file mode 100644 index 00000000..1509e651 --- /dev/null +++ b/lib/api/v2/routers/ReferralRouter.ts @@ -0,0 +1,249 @@ +import { Request, Response, Router } from 'express'; +import Logger from '../../../Logger'; +import ReferralStats from '../../../data/ReferralStats'; +import Stats from '../../../data/Stats'; +import Referral from '../../../db/models/Referral'; +import Bouncer from '../../Bouncer'; +import { errorResponse, successResponse } from '../../Utils'; +import RouterBase from './RouterBase'; + +class ReferralRouter extends RouterBase { + constructor(logger: Logger) { + super(logger, 'referral'); + } + + public getRouter = () => { + const router = Router(); + + /** + * @openapi + * tags: + * name: Referral + * description: Referral related endpoints + */ + + /** + * @openapi + * /referral: + * get: + * description: Referral ID for the used API keys + * tags: [Referral] + * parameters: + * - in: header + * name: TS + * required: true + * schema: + * type: string + * description: Current UNIX timestamp when the request is sent + * - in: header + * name: API-KEY + * required: true + * schema: + * type: string + * description: Your API key + * - in: header + * name: API-HMAC + * required: true + * schema: + * type: string + * description: HMAC-SHA256 with your API-Secret as key of the TS + HTTP method (all uppercase) + the HTTP path + * responses: + * '200': + * description: The referral ID for your API-KEY to be used when creating Swaps + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The referral ID for your API-KEY + * '401': + * description: Unauthorized in case of an unknown API-KEY or bad HMAC + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + router.get('/', this.handleError(this.getName)); + + /** + * @openapi + * /referral/fees: + * get: + * description: Referral fees collected for an ID + * tags: [Referral] + * parameters: + * - in: header + * name: TS + * required: true + * schema: + * type: string + * description: Current UNIX timestamp when the request is sent + * - in: header + * name: API-KEY + * required: true + * schema: + * type: string + * description: Your API key + * - in: header + * name: API-HMAC + * required: true + * schema: + * type: string + * description: HMAC-SHA256 with your API-Secret as key of the TS + HTTP method (all uppercase) + the HTTP path + * responses: + * '200': + * description: The referral ID for your API-KEY to be used when creating Swaps + * content: + * application/json: + * schema: + * type: object + * description: Year + * additionalProperties: + * type: object + * description: Month + * additionalProperties: + * type: object + * description: Fees collected in that month + * additionalProperties: + * type: string + * description: Fees collected in that currency in satoshis + * examples: + * json: + * value: '{"2024":{"1":{"BTC":307}}}' + * '401': + * description: Unauthorized in case of an unknown API-KEY or bad HMAC + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + router.get('/fees', this.handleError(this.getFees)); + + /** + * @openapi + * /referral/stats: + * get: + * description: Statistics for Swaps created with an referral ID + * tags: [Referral] + * parameters: + * - in: header + * name: TS + * required: true + * schema: + * type: string + * description: Current UNIX timestamp when the request is sent + * - in: header + * name: API-KEY + * required: true + * schema: + * type: string + * description: Your API key + * - in: header + * name: API-HMAC + * required: true + * schema: + * type: string + * description: HMAC-SHA256 with your API-Secret as key of the TS + HTTP method (all uppercase) + the HTTP path + * responses: + * '200': + * description: Swap statistics + * content: + * application/json: + * schema: + * type: object + * description: Year + * additionalProperties: + * type: object + * description: Month + * additionalProperties: + * type: object + * description: Swap statistics for that month + * properties: + * volume: + * description: Swap volume + * properties: + * total: + * type: string + * description: Volume across all pairs in BTC + * additionalProperties: + * type: string + * description: Volume in that pair in BTC + * trades: + * type: object + * description: Swap counts + * properties: + * total: + * type: integer + * description: Swap count across all pairs + * additionalProperties: + * type: integer + * description: Swap count for that pair + * failureRates: + * type: object + * description: Swap failure rates for each type + * properties: + * swaps: + * type: number + * description: Submarine Swap failure rate + * reverseSwaps: + * type: number + * description: Reverse Swap failure rate + * examples: + * json: + * value: '{"2024":{"1":{"volume":{"total":"0.00321844","L-BTC/BTC":"0.00321844"},"trades":{"total":3,"L-BTC/BTC":3},"failureRates":{"swaps": 0.12, "reverseSwaps":0}}}}' + * '401': + * description: Unauthorized in case of an unknown API-KEY or bad HMAC + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ + router.get('/stats', this.handleError(this.getStats)); + + return router; + }; + + private getName = async (req: Request, res: Response) => { + const referral = await this.checkAuthentication(req, res); + if (referral === undefined) { + return; + } + + successResponse(res, { id: referral.id }); + }; + + private getFees = async (req: Request, res: Response) => { + const referral = await this.checkAuthentication(req, res); + if (referral === undefined) { + return; + } + + successResponse(res, await ReferralStats.getReferralFees(referral.id)); + }; + + private getStats = async (req: Request, res: Response) => { + const referral = await this.checkAuthentication(req, res); + if (referral === undefined) { + return; + } + + successResponse(res, await Stats.generate(0, 0, referral.id)); + }; + + private checkAuthentication = async ( + req: Request, + res: Response, + ): Promise => { + try { + return await Bouncer.validateRequestAuthentication(req); + } catch (e) { + errorResponse(this.logger, req, res, e, 401); + } + + return; + }; +} + +export default ReferralRouter; diff --git a/lib/api/v2/routers/RouterBase.ts b/lib/api/v2/routers/RouterBase.ts index bdafc882..6deefe30 100644 --- a/lib/api/v2/routers/RouterBase.ts +++ b/lib/api/v2/routers/RouterBase.ts @@ -1,7 +1,6 @@ import { Request, Response, Router } from 'express'; import Logger from '../../../Logger'; import { errorResponse } from '../../Utils'; -import { apiPrefix } from '../Consts'; abstract class RouterBase { public readonly path: string; @@ -32,14 +31,7 @@ abstract class RouterBase { try { await handler(req, res); } catch (e) { - errorResponse( - this.logger, - req, - res, - e, - 400, - `${apiPrefix}/${this.path}`, - ); + errorResponse(this.logger, req, res, e, 400); } }; }; diff --git a/lib/cli/commands/QueryReferrals.ts b/lib/cli/commands/QueryReferrals.ts index cc661bc1..fc598c6b 100644 --- a/lib/cli/commands/QueryReferrals.ts +++ b/lib/cli/commands/QueryReferrals.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { AxiosResponse } from 'axios'; import { createHmac } from 'crypto'; import { Arguments } from 'yargs'; import { getUnixTime, stringify } from '../../Utils'; @@ -32,28 +32,39 @@ export const builder = { }; export const handler = async (argv: Arguments): Promise => { - const path = '/referrals/query'; + try { + const [idRes, fees, stats] = await Promise.all([ + sendAuthenticatedRequest<{ id: string }>(argv, '/v2/referral'), + sendAuthenticatedRequest(argv, '/v2/referral/fees'), + sendAuthenticatedRequest(argv, '/v2/referral/stats'), + ]); + + console.log( + stringify({ id: idRes.data.id, fees: fees.data, stats: stats.data }), + ); + } catch (error: any) { + if (error.message && error.response) { + console.error(`${error.message}: ${stringify(error.response.data)}`); + } else { + console.error(error); + } + } +}; +const sendAuthenticatedRequest = ( + argv: Arguments, + path: string, +): Promise> => { const ts = getUnixTime(); const hmac = createHmac('sha256', argv.secret) .update(`${ts}GET${path}`) .digest('hex'); - try { - const res = await axios.get( - `http://${argv.rest.host}:${argv.rest.port}${path}`, - { - headers: { - TS: ts.toString(), - 'API-KEY': argv.key, - 'API-HMAC': hmac, - }, - }, - ); - - console.log(stringify(res.data)); - } catch (e) { - const error = e as any; - console.log(`${error.message}: ${stringify(error.response.data)}`); - } + return axios.get(`http://${argv.rest.host}:${argv.rest.port}${path}`, { + headers: { + TS: ts.toString(), + 'API-KEY': argv.key, + 'API-HMAC': hmac, + }, + }); }; diff --git a/lib/data/ReferralStats.ts b/lib/data/ReferralStats.ts index ba061e9d..39a82cca 100644 --- a/lib/data/ReferralStats.ts +++ b/lib/data/ReferralStats.ts @@ -2,20 +2,21 @@ import { splitPairId } from '../Utils'; import ReferralRepository from '../db/repositories/ReferralRepository'; import { getNestedObject } from './Utils'; +type Stats = Record>>; + class ReferralStats { - public static generate = async ( + public static getReferralFees = async ( referralKey?: string, - ): Promise< - Record>>> - > => { + ): Promise> => { const data = await ReferralRepository.getReferralSum(referralKey); const years = {}; data.forEach((ref) => { - const assets = getNestedObject( - getNestedObject(getNestedObject(years, ref.year), ref.month), - ref.referral, - ); + let assets = getNestedObject(getNestedObject(years, ref.year), ref.month); + + if (referralKey === undefined) { + assets = getNestedObject(assets, ref.referral); + } const { quote } = splitPairId(ref.pair); assets[quote] = (assets[quote] || 0) + ref.sum; diff --git a/lib/data/Stats.ts b/lib/data/Stats.ts index c865963f..a2478ef0 100644 --- a/lib/data/Stats.ts +++ b/lib/data/Stats.ts @@ -17,11 +17,12 @@ class Stats { public static generate = async ( minYear: number, minMonth: number, + referral?: string, ): Promise>> => { const [volumes, tradeCounts, failureRates] = await Promise.all([ - StatsRepository.getVolume(minYear, minMonth), - StatsRepository.getTradeCounts(minYear, minMonth), - StatsRepository.getFailureRates(minYear, minMonth), + StatsRepository.getVolume(minYear, minMonth, referral), + StatsRepository.getTradeCounts(minYear, minMonth, referral), + StatsRepository.getFailureRates(minYear, minMonth, referral), ]); const stats = {}; diff --git a/lib/db/repositories/StatsRepository.ts b/lib/db/repositories/StatsRepository.ts index 0697fad0..4b165bb8 100644 --- a/lib/db/repositories/StatsRepository.ts +++ b/lib/db/repositories/StatsRepository.ts @@ -63,6 +63,7 @@ WITH data AS ( SELECT pair, status, + referral, CASE WHEN "orderSide" = 1 THEN "invoiceAmount" ELSE "onchainAmount" @@ -74,6 +75,7 @@ WITH data AS ( SELECT pair, status, + referral, CASE WHEN "orderSide" = 1 THEN "onchainAmount" ELSE "invoiceAmount" @@ -88,10 +90,12 @@ SELECT pair, SUM(amount) AS sum FROM data -WHERE ( +WHERE + CASE WHEN ? IS NOT NULL THEN referral = ? ELSE TRUE END + AND (( EXTRACT(YEAR FROM "createdAt") >= ? AND EXTRACT(MONTH FROM "createdAt") >= ? - ) OR EXTRACT(YEAR from "createdAt") > ? + ) OR EXTRACT(YEAR from "createdAt") > ?) GROUP BY GROUPING SETS ( (year, month), (year, month, pair) @@ -105,6 +109,7 @@ WITH data AS ( SELECT pair, status, + referral, CASE WHEN orderSide THEN invoiceAmount ELSE onchainAmount END AS amount, createdAt FROM swaps @@ -113,6 +118,7 @@ WITH data AS ( SELECT pair, status, + referral, CASE WHEN orderSide THEN onchainAmount ELSE invoiceAmount END AS amount, createdAt FROM reverseSwaps @@ -124,6 +130,7 @@ WITH data AS ( pair, SUM(amount) AS sum FROM data + WHERE CASE WHEN ? IS NOT NULL THEN referral = ? ELSE TRUE END GROUP BY year, month, pair ORDER BY year, month, pair ), groupedTotals AS ( @@ -143,11 +150,11 @@ ORDER BY year, month, pair; // language=PostgreSQL [DatabaseType.PostgreSQL]: ` WITH data AS ( - SELECT pair, status, "createdAt" + SELECT pair, status, referral, "createdAt" FROM swaps WHERE status = ? UNION ALL - SELECT pair, status, "createdAt" + SELECT pair, status, referral, "createdAt" FROM "reverseSwaps" WHERE status = ? ) @@ -157,10 +164,12 @@ SELECT pair, COUNT(*) AS count FROM data -WHERE ( +WHERE + CASE WHEN ? IS NOT NULL THEN referral = ? ELSE TRUE END + AND (( EXTRACT(YEAR FROM "createdAt") >= ? AND EXTRACT(MONTH FROM "createdAt") >= ? - ) OR EXTRACT(YEAR FROM "createdAt") > ? + ) OR EXTRACT(YEAR FROM "createdAt") > ?) GROUP BY GROUPING SETS ( (year, month), (pair, year, month) @@ -171,11 +180,11 @@ ORDER BY year, month, pair NULLS FIRST; // language=SQLite [DatabaseType.SQLite]: ` WITH data AS ( - SELECT pair, status, createdAt + SELECT pair, status, referral, createdAt FROM swaps WHERE status = ? UNION ALL - SELECT pair, status, createdAt + SELECT pair, status, referral, createdAt FROM reverseSwaps WHERE status = ? ), groupedSwaps AS ( @@ -185,6 +194,7 @@ WITH data AS ( pair, COUNT(*) AS count FROM data + WHERE CASE WHEN ? IS NOT NULL THEN referral = ? ELSE TRUE END GROUP BY pair, year, month ORDER BY year, month ), groupedTotals AS ( @@ -204,9 +214,9 @@ ORDER BY year, month, pair; // language=PostgreSQL [DatabaseType.PostgreSQL]: ` WITH data AS ( - SELECT pair, false AS "isReverse", status, "createdAt" FROM swaps + SELECT pair, false AS "isReverse", status, referral, "createdAt" FROM swaps UNION ALL - SELECT pair, true as "isReverse", status, "createdAt" FROM "reverseSwaps" + SELECT pair, true as "isReverse", status, referral, "createdAt" FROM "reverseSwaps" ) SELECT EXTRACT(YEAR FROM "createdAt") AS year, @@ -216,10 +226,12 @@ SELECT WHERE status IN (?) ) / CAST(COUNT(*) AS REAL) AS "failureRate" FROM data -WHERE ( +WHERE + CASE WHEN ? IS NOT NULL THEN referral = ? ELSE TRUE END + AND (( EXTRACT(YEAR FROM "createdAt") >= ? AND EXTRACT(MONTH FROM "createdAt") >= ? - ) OR EXTRACT(YEAR FROM "createdAt") > ? + ) OR EXTRACT(YEAR FROM "createdAt") > ?) GROUP BY year, month, "isReverse" ORDER BY year, month, "isReverse"; `, @@ -227,9 +239,9 @@ ORDER BY year, month, "isReverse"; // language=SQLite [DatabaseType.SQLite]: ` WITH data AS ( - SELECT pair, false AS isReverse, status, createdAt FROM swaps + SELECT pair, false AS isReverse, status, referral, createdAt FROM swaps UNION ALL - SELECT pair, true as isReverse, status, createdAt FROM reverseSwaps + SELECT pair, true as isReverse, status, referral, createdAt FROM reverseSwaps ) SELECT CAST(STRFTIME('%Y', createdAt) AS INT) AS year, @@ -240,7 +252,9 @@ SELECT WHERE status IN (?) ) / CAST(COUNT(*) AS REAL) AS failureRate FROM data -WHERE (year >= ? AND month >= ?) OR year > ? +WHERE + CASE WHEN ? IS NOT NULL THEN referral = ? ELSE TRUE END AND + ((year >= ? AND month >= ?) OR year > ?) GROUP BY year, month, isReverse ORDER BY year, month, isReverse; `, @@ -445,14 +459,17 @@ GROUP BY pair; }; public static getVolume = ( - minYear: number, - minMonth: number, + minYear: number = 0, + minMonth: number = 0, + referral: string | null = null, ): Promise => { return StatsRepository.query({ query: StatsRepository.queryVolume[Database.type], values: [ SwapUpdateEvent.TransactionClaimed, SwapUpdateEvent.InvoiceSettled, + referral, + referral, minYear, minMonth, minYear, @@ -461,14 +478,17 @@ GROUP BY pair; }; public static getTradeCounts = ( - minYear: number, - minMonth: number, + minYear: number = 0, + minMonth: number = 0, + referral: string | null = null, ): Promise => { return StatsRepository.query({ query: StatsRepository.queryTradeCounts[Database.type], values: [ SwapUpdateEvent.TransactionClaimed, SwapUpdateEvent.InvoiceSettled, + referral, + referral, minYear, minMonth, minYear, @@ -477,8 +497,9 @@ GROUP BY pair; }; public static getFailureRates = ( - minYear: number, - minMonth: number, + minYear: number = 0, + minMonth: number = 0, + referral: string | null = null, ): Promise => { return StatsRepository.query({ query: StatsRepository.queryFailureRates[Database.type], @@ -488,6 +509,8 @@ GROUP BY pair; SwapUpdateEvent.InvoiceFailedToPay, SwapUpdateEvent.TransactionRefunded, ], + referral, + referral, minYear, minMonth, minYear, diff --git a/lib/notifications/CommandHandler.ts b/lib/notifications/CommandHandler.ts index d12df6af..d291f0a8 100644 --- a/lib/notifications/CommandHandler.ts +++ b/lib/notifications/CommandHandler.ts @@ -463,7 +463,7 @@ class CommandHandler { private getReferrals = async () => { await this.discord.sendMessage( - `${codeBlock}${stringify(await ReferralStats.generate())}${codeBlock}`, + `${codeBlock}${stringify(await ReferralStats.getReferralFees())}${codeBlock}`, ); }; diff --git a/swagger-spec.json b/swagger-spec.json index c5862f2a..46584416 100644 --- a/swagger-spec.json +++ b/swagger-spec.json @@ -310,6 +310,264 @@ } } }, + "/referral": { + "get": { + "description": "Referral ID for the used API keys", + "tags": [ + "Referral" + ], + "parameters": [ + { + "in": "header", + "name": "TS", + "required": true, + "schema": { + "type": "string" + }, + "description": "Current UNIX timestamp when the request is sent" + }, + { + "in": "header", + "name": "API-KEY", + "required": true, + "schema": { + "type": "string" + }, + "description": "Your API key" + }, + { + "in": "header", + "name": "API-HMAC", + "required": true, + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 with your API-Secret as key of the TS + HTTP method (all uppercase) + the HTTP path" + } + ], + "responses": { + "200": { + "description": "The referral ID for your API-KEY to be used when creating Swaps", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The referral ID for your API-KEY" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized in case of an unknown API-KEY or bad HMAC", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/referral/fees": { + "get": { + "description": "Referral fees collected for an ID", + "tags": [ + "Referral" + ], + "parameters": [ + { + "in": "header", + "name": "TS", + "required": true, + "schema": { + "type": "string" + }, + "description": "Current UNIX timestamp when the request is sent" + }, + { + "in": "header", + "name": "API-KEY", + "required": true, + "schema": { + "type": "string" + }, + "description": "Your API key" + }, + { + "in": "header", + "name": "API-HMAC", + "required": true, + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 with your API-Secret as key of the TS + HTTP method (all uppercase) + the HTTP path" + } + ], + "responses": { + "200": { + "description": "The referral ID for your API-KEY to be used when creating Swaps", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Year", + "additionalProperties": { + "type": "object", + "description": "Month", + "additionalProperties": { + "type": "object", + "description": "Fees collected in that month", + "additionalProperties": { + "type": "string", + "description": "Fees collected in that currency in satoshis" + } + } + } + }, + "examples": { + "json": { + "value": "{\"2024\":{\"1\":{\"BTC\":307}}}" + } + } + } + } + }, + "401": { + "description": "Unauthorized in case of an unknown API-KEY or bad HMAC", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/referral/stats": { + "get": { + "description": "Statistics for Swaps created with an referral ID", + "tags": [ + "Referral" + ], + "parameters": [ + { + "in": "header", + "name": "TS", + "required": true, + "schema": { + "type": "string" + }, + "description": "Current UNIX timestamp when the request is sent" + }, + { + "in": "header", + "name": "API-KEY", + "required": true, + "schema": { + "type": "string" + }, + "description": "Your API key" + }, + { + "in": "header", + "name": "API-HMAC", + "required": true, + "schema": { + "type": "string" + }, + "description": "HMAC-SHA256 with your API-Secret as key of the TS + HTTP method (all uppercase) + the HTTP path" + } + ], + "responses": { + "200": { + "description": "Swap statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Year", + "additionalProperties": { + "type": "object", + "description": "Month", + "additionalProperties": { + "type": "object", + "description": "Swap statistics for that month", + "properties": { + "volume": { + "description": "Swap volume", + "properties": { + "total": { + "type": "string", + "description": "Volume across all pairs in BTC" + } + }, + "additionalProperties": { + "type": "string", + "description": "Volume in that pair in BTC" + } + }, + "trades": { + "type": "object", + "description": "Swap counts", + "properties": { + "total": { + "type": "integer", + "description": "Swap count across all pairs" + } + }, + "additionalProperties": { + "type": "integer", + "description": "Swap count for that pair" + } + }, + "failureRates": { + "type": "object", + "description": "Swap failure rates for each type", + "properties": { + "swaps": { + "type": "number", + "description": "Submarine Swap failure rate" + }, + "reverseSwaps": { + "type": "number", + "description": "Reverse Swap failure rate" + } + } + } + } + } + } + }, + "examples": { + "json": { + "value": "{\"2024\":{\"1\":{\"volume\":{\"total\":\"0.00321844\",\"L-BTC/BTC\":\"0.00321844\"},\"trades\":{\"total\":3,\"L-BTC/BTC\":3},\"failureRates\":{\"swaps\": 0.12, \"reverseSwaps\":0}}}}" + } + } + } + } + }, + "401": { + "description": "Unauthorized in case of an unknown API-KEY or bad HMAC", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/swap/submarine": { "get": { "description": "Possible pairs for Submarine Swaps", @@ -1035,6 +1293,10 @@ "name": "Nodes", "description": "Lightning nodes" }, + { + "name": "Referral", + "description": "Referral related endpoints" + }, { "name": "Submarine", "description": "Submarine Swap related endpoints" diff --git a/test/unit/api/Bouncer.spec.ts b/test/unit/api/Bouncer.spec.ts index f18511ae..374e95c0 100644 --- a/test/unit/api/Bouncer.spec.ts +++ b/test/unit/api/Bouncer.spec.ts @@ -33,8 +33,8 @@ describe('Bouncer', () => { const req = { method: 'POST', - path: '/some/path', rawBody: 'raw body', + originalUrl: '/some/path', get: mockReqGet, } as any; @@ -48,7 +48,7 @@ describe('Bouncer', () => { ); providedHmac = createHmac('sha256', mockGetReferralByApiKeyResult.apiSecret) - .update(`${time}${req.method}${req.path}${req.rawBody}`) + .update(`${time}${req.method}${req.originalUrl}${req.rawBody}`) .digest('hex'); expect(await Bouncer.validateRequestAuthentication(req)).toEqual( diff --git a/test/unit/api/Controller.spec.ts b/test/unit/api/Controller.spec.ts index 7c927f34..d31c820a 100644 --- a/test/unit/api/Controller.spec.ts +++ b/test/unit/api/Controller.spec.ts @@ -241,7 +241,7 @@ describe('Controller', () => { const controller = new Controller(Logger.disabledLogger, service); beforeEach(() => { - ReferralStats.generate = mockGenerateReferralStats; + ReferralStats.getReferralFees = mockGenerateReferralStats; SwapRepository.getSwaps = () => Promise.resolve(swaps); ReverseSwapRepository.getReverseSwaps = () => Promise.resolve(reverseSwaps); diff --git a/test/unit/api/v2/ApiV2.spec.ts b/test/unit/api/v2/ApiV2.spec.ts index 3095be0b..9764c796 100644 --- a/test/unit/api/v2/ApiV2.spec.ts +++ b/test/unit/api/v2/ApiV2.spec.ts @@ -47,6 +47,17 @@ jest.mock('../../../../lib/api/v2/routers/ChainRouter', () => { }); }); +const mockGetReferralRouter = jest.fn().mockReturnValue(Router()); + +jest.mock('../../../../lib/api/v2/routers/ReferralRouter', () => { + return jest.fn().mockImplementation(() => { + return { + path: 'referral', + getRouter: mockGetReferralRouter, + }; + }); +}); + describe('ApiV2', () => { beforeEach(() => { jest.clearAllMocks(); @@ -64,7 +75,7 @@ describe('ApiV2', () => { expect(mockNodesGetRouter).toHaveBeenCalledTimes(1); expect(mockGetChainRouter).toHaveBeenCalledTimes(1); - expect(app.use).toHaveBeenCalledTimes(4); + expect(app.use).toHaveBeenCalledTimes(5); expect(app.use).toHaveBeenCalledWith(`${apiPrefix}/`, mockGetInfoRouter()); expect(app.use).toHaveBeenCalledWith( `${apiPrefix}/swap`, @@ -78,5 +89,9 @@ describe('ApiV2', () => { `${apiPrefix}/nodes`, mockNodesGetRouter(), ); + expect(app.use).toHaveBeenCalledWith( + `${apiPrefix}/referral`, + mockGetReferralRouter(), + ); }); }); diff --git a/test/unit/api/v2/routers/ReferralRouter.spec.ts b/test/unit/api/v2/routers/ReferralRouter.spec.ts new file mode 100644 index 00000000..ae31436e --- /dev/null +++ b/test/unit/api/v2/routers/ReferralRouter.spec.ts @@ -0,0 +1,162 @@ +import { Router } from 'express'; +import Logger from '../../../../../lib/Logger'; +import Bouncer from '../../../../../lib/api/Bouncer'; +import ReferralRouter from '../../../../../lib/api/v2/routers/ReferralRouter'; +import ReferralStats from '../../../../../lib/data/ReferralStats'; +import Stats from '../../../../../lib/data/Stats'; +import { mockRequest, mockResponse } from '../../Utils'; + +const mockedRouter = { + get: jest.fn(), +}; + +jest.mock('express', () => { + return { + Router: jest.fn().mockImplementation(() => mockedRouter), + }; +}); + +ReferralStats.getReferralFees = jest.fn().mockResolvedValue({ + 2024: { + 1: { + some: 'data', + }, + }, +}); + +Stats.generate = jest.fn().mockResolvedValue({ + 2023: { + 2: { + stats: 'up only', + }, + }, +}); + +let mockValidateAuthResult: any = null; +Bouncer.validateRequestAuthentication = jest.fn().mockImplementation(() => { + if (mockValidateAuthResult === null) { + throw 'unauthorized'; + } else { + return mockValidateAuthResult; + } +}); + +describe('ReferralRouter', () => { + const referralRouter = new ReferralRouter(Logger.disabledLogger); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should get route prefix', () => { + expect(referralRouter.path).toEqual('referral'); + }); + + test('should get router', () => { + const router = referralRouter.getRouter(); + expect(router).not.toBeUndefined(); + + expect(Router).toHaveBeenCalledTimes(1); + + expect(mockedRouter.get).toHaveBeenCalledTimes(3); + expect(mockedRouter.get).toHaveBeenCalledWith('/', expect.anything()); + expect(mockedRouter.get).toHaveBeenCalledWith('/fees', expect.anything()); + expect(mockedRouter.get).toHaveBeenCalledWith('/stats', expect.anything()); + }); + + test('should get name of referral id', async () => { + mockValidateAuthResult = { id: 'partner', other: 'data' }; + + const res = mockResponse(); + await referralRouter['getName'](mockRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ id: mockValidateAuthResult.id }); + }); + + test('should get fees of referral id', async () => { + mockValidateAuthResult = { id: 'partner', other: 'data' }; + + const res = mockResponse(); + await referralRouter['getFees'](mockRequest(), res); + + expect(ReferralStats.getReferralFees).toHaveBeenCalledTimes(1); + expect(ReferralStats.getReferralFees).toHaveBeenCalledWith( + mockValidateAuthResult.id, + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + await ReferralStats.getReferralFees(''), + ); + }); + + test('should get stats of referral id', async () => { + mockValidateAuthResult = { id: 'partner', other: 'data' }; + + const res = mockResponse(); + await referralRouter['getStats'](mockRequest(), res); + + expect(Stats.generate).toHaveBeenCalledTimes(1); + expect(Stats.generate).toHaveBeenCalledWith( + 0, + 0, + mockValidateAuthResult.id, + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(await Stats.generate(0, 0, '')); + }); + + test.each` + name | func + ${'name'} | ${'getName'} + ${'fees'} | ${'getFees'} + ${'stats'} | ${'getStats'} + `( + 'should not get $name of referral id with invalid auth', + async ({ func }) => { + mockValidateAuthResult = null; + + const res = mockResponse(); + await referralRouter[func](mockRequest(), res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'unauthorized' }); + }, + ); + + test('should return referral with valid authentication', async () => { + mockValidateAuthResult = { + some: 'data', + }; + + const res = mockResponse(); + const referral = await referralRouter['checkAuthentication']( + mockRequest(), + res, + ); + expect(referral).toEqual(mockValidateAuthResult); + + expect(Bouncer.validateRequestAuthentication).toHaveBeenCalledWith( + mockRequest(), + ); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); + + test('should write error response with invalid authentication', async () => { + mockValidateAuthResult = null; + + const res = mockResponse(); + await referralRouter['checkAuthentication'](mockRequest(), res); + + expect(Bouncer.validateRequestAuthentication).toHaveBeenCalledWith( + mockRequest(), + ); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'unauthorized' }); + }); +}); diff --git a/test/unit/api/v2/routers/RouterBase.spec.ts b/test/unit/api/v2/routers/RouterBase.spec.ts index eed34e53..d6ba3e4f 100644 --- a/test/unit/api/v2/routers/RouterBase.spec.ts +++ b/test/unit/api/v2/routers/RouterBase.spec.ts @@ -1,7 +1,6 @@ import { Router } from 'express'; import Logger from '../../../../../lib/Logger'; import { errorResponse } from '../../../../../lib/api/Utils'; -import { apiPrefix } from '../../../../../lib/api/v2/Consts'; import RouterBase from '../../../../../lib/api/v2/routers/RouterBase'; import { mockRequest, mockResponse } from '../../Utils'; @@ -39,7 +38,6 @@ describe('RouterBase', () => { expect.anything(), msg, 400, - `${apiPrefix}/${new TestRouter().path}`, ); }); @@ -54,7 +52,6 @@ describe('RouterBase', () => { expect.anything(), msg, 400, - `${apiPrefix}/${new TestRouter().path}`, ); }); }); diff --git a/test/unit/notifications/CommandHandler.spec.ts b/test/unit/notifications/CommandHandler.spec.ts index 56db250c..216c384a 100644 --- a/test/unit/notifications/CommandHandler.spec.ts +++ b/test/unit/notifications/CommandHandler.spec.ts @@ -198,7 +198,7 @@ describe('CommandHandler', () => { beforeEach(() => { mockSendMessage.mockClear(); - ReferralStats.generate = mockGenerateReferralStats; + ReferralStats.getReferralFees = mockGenerateReferralStats; }); test('should not respond to messages that are not commands', async () => {