-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
32 changed files
with
1,660 additions
and
506 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
Oops, something went wrong.