Skip to content

Commit

Permalink
merged:staging
Browse files Browse the repository at this point in the history
  • Loading branch information
gianfra-t committed Nov 12, 2024
2 parents bf5d521 + 56110f4 commit 9e92784
Show file tree
Hide file tree
Showing 32 changed files with 1,660 additions and 506 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
"stellar-sdk": "^11.3.0",
"tailwind": "^4.0.0",
"tailwindcss": "^3.4.3",
"viem": "2.x",
"wagmi": "^2.10.3",
"viem": "^2.21.43",
"wagmi": "^2.12.29",
"web3": "^4.10.0",
"yup": "^1.4.0"
},
Expand Down
52 changes: 52 additions & 0 deletions signer-service/src/api/controllers/quote.controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require('dotenv').config();

const alchemyPayService = require('../services/alchemypay.service');
const transakService = require('../services/transak.service');
const moonpayService = require('../services/moonpay.service');

exports.SUPPORTED_PROVIDERS = ['alchemypay', 'moonpay', 'transak'];

exports.SUPPORTED_CRYPTO_CURRENCIES = ['usdc', 'usdce', 'usdc.e'];

exports.SUPPORTED_FIAT_CURRENCIES = ['eur', 'ars'];

exports.getQuoteForProvider = async (req, res, next) => {
const { provider, fromCrypto, toFiat, amount, network } = req.query;
try {
switch (provider.toLowerCase()) {
case 'alchemypay':
try {
const alchemyPayQuote = await alchemyPayService.getQuoteFor(fromCrypto, toFiat, amount, network);
return res.json(alchemyPayQuote);
} catch (error) {
// AlchemyPay's errors are not very descriptive, so we just return a generic error message
return res.status(500).json({ error: 'Could not get quote from AlchemyPay', details: error.message });
}
case 'moonpay':
try {
const moonpayQuote = await moonpayService.getQuoteFor(fromCrypto, toFiat, amount);
return res.json(moonpayQuote);
} catch (error) {
if (error.message === 'Token not supported') {
return res.status(404).json({ error: 'Token not supported' });
}
return res.status(500).json({ error: 'Could not get quote from Moonpay', details: error.message });
}
case 'transak':
try {
const transakQuote = await transakService.getQuoteFor(fromCrypto, toFiat, amount, network);
return res.json(transakQuote);
} catch (error) {
if (error.message === 'Token not supported') {
return res.status(404).json({ error: 'Token not supported' });
}
return res.status(500).json({ error: 'Could not get quote from Transak', details: error.message });
}
default:
return res.status(400).json({ error: 'Invalid provider' });
}
} catch (error) {
console.error('Server error:', error);
return res.status(500).json({ error: 'Server error', details: error.message });
}
};
42 changes: 42 additions & 0 deletions signer-service/src/api/middlewares/validators.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ const { TOKEN_CONFIG } = require('../../constants/tokenConfig');
const { DUMP_SHEET_HEADER_VALUES } = require('../controllers/storage.controller');
const { EMAIL_SHEET_HEADER_VALUES } = require('../controllers/email.controller');
const { RATING_SHEET_HEADER_VALUES } = require('../controllers/rating.controller');
const {
SUPPORTED_PROVIDERS,
SUPPORTED_CRYPTO_CURRENCIES,
SUPPORTED_FIAT_CURRENCIES,
} = require('../controllers/quote.controller');

const validateCreationInput = (req, res, next) => {
const { accountId, maxTime, assetCode } = req.body;
Expand All @@ -19,6 +24,42 @@ const validateCreationInput = (req, res, next) => {
next();
};

const validateQuoteInput = (req, res, next) => {
const { provider, fromCrypto, toFiat, amount, network } = req.query;

if (!provider || SUPPORTED_PROVIDERS.indexOf(provider.toLowerCase()) === -1) {
return res
.status(400)
.json({ error: 'Invalid provider. Supported providers are: ' + SUPPORTED_PROVIDERS.join(', ') });
}

if (!fromCrypto || SUPPORTED_CRYPTO_CURRENCIES.indexOf(fromCrypto.toLowerCase()) === -1) {
return res
.status(400)
.json({ error: 'Invalid fromCrypto. Supported currencies are: ' + SUPPORTED_CRYPTO_CURRENCIES.join(', ') });
}

if (!toFiat || SUPPORTED_FIAT_CURRENCIES.indexOf(toFiat.toLowerCase()) === -1) {
return res
.status(400)
.json({ error: 'Invalid toFiat. Supported currencies are: ' + SUPPORTED_FIAT_CURRENCIES.join(', ') });
}

if (!amount) {
return res.status(400).json({ error: 'Missing amount parameter' });
}

if (!network) {
return res.status(400).json({ error: 'Missing network parameter' });
}

if (isNaN(parseFloat(amount))) {
return res.status(400).json({ error: 'Invalid amount parameter. Not a number.' });
}

next();
};

const validateChangeOpInput = (req, res, next) => {
const { accountId, sequence, paymentData, maxTime, assetCode } = req.body;
if (!accountId || !sequence || !paymentData || !maxTime) {
Expand Down Expand Up @@ -116,6 +157,7 @@ const validateSep10Input = (req, res, next) => {

module.exports = {
validateChangeOpInput,
validateQuoteInput,
validateCreationInput,
validatePreSwapSubsidizationInput,
validatePostSwapSubsidizationInput,
Expand Down
7 changes: 7 additions & 0 deletions signer-service/src/api/routes/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const emailRoutes = require('./email.route');
const ratingRoutes = require('./rating.route');
const subsidizeRoutes = require('./subsidize.route');
const siweRoutes = require('./siwe.route');
const quoteRoutes = require('./quote.route');

const router = express.Router({ mergeParams: true });
const { sendStatusWithPk: sendStellarStatusWithPk } = require('../../services/stellar.service');
const { sendStatusWithPk: sendPendulumStatusWithPk } = require('../../services/pendulum.service');
Expand Down Expand Up @@ -35,6 +37,11 @@ router.get('/status', sendStatusWithPk);
// Don't show docs for now.
// router.use("/docs", express.static("docs"));

/**
* GET v1/quotes
*/
router.use('/quotes', quoteRoutes);

/**
* POST v1/stellar
*/
Expand Down
9 changes: 9 additions & 0 deletions signer-service/src/api/routes/v1/quote.route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const express = require('express');
const controller = require('../../controllers/quote.controller');
const { validateQuoteInput } = require('../../middlewares/validators');

const router = express.Router({ mergeParams: true });

router.route('/').get(validateQuoteInput, controller.getQuoteForProvider);

module.exports = router;
219 changes: 219 additions & 0 deletions signer-service/src/api/services/alchemypay.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
const crypto = require('crypto');
const { quoteProviders } = require('../../config/vars');

function apiSign(timestamp, method, requestUrl, body, secretKey) {
const content = timestamp + method.toUpperCase() + getPath(requestUrl) + getJsonBody(body);
return crypto.createHmac('sha256', secretKey).update(content).digest('base64');
}

function getPath(requestUrl) {
const uri = new URL(requestUrl);
const path = uri.pathname;
const params = Array.from(uri.searchParams.entries());

if (params.length === 0) {
return path;
} else {
const sortedParams = [...params].sort(([aKey], [bKey]) => aKey.localeCompare(bKey));
const queryString = sortedParams.map(([key, value]) => `${key}=${value}`).join('&');
return `${path}?${queryString}`;
}
}

function getJsonBody(body) {
let map;

try {
map = JSON.parse(body);
} catch (error) {
map = {};
console.error("Couldn't parse JSON body", error);
}

if (Object.keys(map).length === 0) {
return '';
}

map = removeEmptyKeys(map);
map = sortObject(map);

return JSON.stringify(map);
}

function removeEmptyKeys(map) {
const retMap = {};

for (const [key, value] of Object.entries(map)) {
if (value !== null && value !== '') {
retMap[key] = value;
}
}

return retMap;
}

function sortObject(obj) {
if (typeof obj === 'object') {
if (Array.isArray(obj)) {
return sortList(obj);
} else {
return sortMap(obj);
}
}

return obj;
}

function sortMap(map) {
const sortedMap = new Map(Object.entries(removeEmptyKeys(map)).sort(([aKey], [bKey]) => aKey.localeCompare(bKey)));

for (const [key, value] of sortedMap.entries()) {
if (typeof value === 'object') {
sortedMap.set(key, sortObject(value));
}
}

return Object.fromEntries(sortedMap.entries());
}

function sortList(list) {
const objectList = [];
const intList = [];
const floatList = [];
const stringList = [];
const jsonArray = [];

for (const item of list) {
if (typeof item === 'object') {
jsonArray.push(item);
} else if (Number.isInteger(item)) {
intList.push(item);
} else if (typeof item === 'number') {
floatList.push(item);
} else if (typeof item === 'string') {
stringList.push(item);
} else {
intList.push(item);
}
}

intList.sort((a, b) => a - b);
floatList.sort((a, b) => a - b);
stringList.sort();

objectList.push(...intList, ...floatList, ...stringList, ...jsonArray);
list.length = 0;
list.push(...objectList);

const retList = [];

for (const item of list) {
if (typeof item === 'object') {
retList.push(sortObject(item));
} else {
retList.push(item);
}
}

return retList;
}

// See https://alchemypay.readme.io/docs/price-query
function priceQuery(crypto, fiat, amount, network, side) {
const { secretKey, baseUrl, appId } = quoteProviders.alchemyPay;
const httpMethod = 'POST';
const requestPath = '/open/api/v4/merchant/order/quote';
const requestUrl = baseUrl + requestPath;
const timestamp = String(Date.now());

const bodyString = JSON.stringify({
crypto,
network,
fiat,
amount,
side,
});
// It's important to sort the body before signing. It's also important for the POST request to have the body sorted.
const sortedBody = getJsonBody(bodyString);

const signature = apiSign(timestamp, httpMethod, requestUrl, sortedBody, secretKey.trim());

const headers = {
'Content-Type': 'application/json',
appId,
timestamp,
sign: signature,
};

const request = {
method: 'POST',
headers,
body: sortedBody,
};

return fetch(requestUrl, request)
.then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const body = await response.json();
if (!body.success) {
throw new Error(
`Could not get quote for ${crypto} to ${fiat} from AlchemyPay: ` + body.returnMsg || 'Unknown error',
);
}

const { cryptoPrice, rampFee, networkFee, fiatQuantity } = body.data;

const totalFee = (Number(rampFee) || 0) + (Number(networkFee) || 0);
// According to a comment in the response sample [here](https://alchemypay.readme.io/docs/price-query#response-sample)
// the `fiatQuantity` does not yet include the fees so we need to subtract them.
const fiatAmount = Number(fiatQuantity) - totalFee;

return {
cryptoPrice: Number(cryptoPrice),
cryptoAmount: Number(amount),
fiatAmount,
totalFee,
};
})
.then((data) => {
if (data.error) {
throw new Error(data.error);
}
return data;
});
}

// see https://alchemypay.readme.io/docs/network-code
function getAlchemyPayNetworkCode(network) {
switch (network.toUpperCase()) {
case 'POLYGON':
return 'MATIC';
default:
return network;
}
}

function getCryptoCurrencyCode(fromCrypto) {
if (fromCrypto.toLowerCase() === 'usdc') {
return 'USDC';
} else if (fromCrypto.toLowerCase() === 'usdce' || fromCrypto.toLowerCase() === 'usdc.e') {
return 'USDC.e';
}

// The currencies need to be in uppercase
return fromCrypto.toUpperCase();
}

function getFiatCode(toFiat) {
// The currencies need to be in uppercase
return toFiat.toUpperCase();
}

exports.getQuoteFor = (fromCrypto, toFiat, amount, network) => {
const networkCode = getAlchemyPayNetworkCode(network);
const side = 'SELL';

return priceQuery(getCryptoCurrencyCode(fromCrypto), getFiatCode(toFiat), amount, networkCode, side);
};
Loading

0 comments on commit 9e92784

Please sign in to comment.