diff --git a/src/partners/criptointercambio.ts b/src/partners/criptointercambio.ts new file mode 100644 index 00000000..229aa9bf --- /dev/null +++ b/src/partners/criptointercambio.ts @@ -0,0 +1,178 @@ +import { asArray, asNumber, asObject, asString } from 'cleaners' + +import { + CicTransaction, + CriptointercambioClient, + PartialCicTransaction +} from '../../util/cic-sdk' +import { PartnerPlugin, PluginParams, PluginResult, StandardTx } from '../types' +import { datelog } from '../util' + +const asCriptointercambioTx = asObject({ + id: asString, + payinHash: asString, + payoutHash: asString, + payinAddress: asString, + currencyFrom: asString, + amountFrom: asString, + payoutAddress: asString, + currencyTo: asString, + amountTo: asString, + createdAt: asNumber +}) + +const asCriptointercambioRawTx = asObject({ + status: asString +}) + +const asCriptointercambioResult = asArray(asCriptointercambioTx) + +const MAX_ATTEMPTS = 3 +const LIMIT = 300 +const QUERY_LOOKBACK = 60 * 60 * 24 * 5 // 5 days + +async function getTransactionsPromised( + criptointercambioSDK: CriptointercambioClient, + limit: number, + offset: number, + currencyFrom: string | undefined, + address: string | undefined, + extraId: string | undefined +): Promise { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return await criptointercambioSDK.getTransactions( + limit, + offset, + currencyFrom, + address, + extraId + ) + } catch (e) { + if (attempt <= MAX_ATTEMPTS) { + datelog( + `Criptointercambio request have failed. Retry attempt: ${attempt} out of ${MAX_ATTEMPTS}` + ) + } else { + throw new Error( + 'Unable to fetch transactions data from Criptointercambio' + ) + } + } + } + // To avoid undefined casting of return result. We always expect to get into the "else" branch at the end + throw new Error('Unable to fetch transactions data from Criptointercambio') +} + +export async function queryCriptointercambio( + pluginParams: PluginParams +): Promise { + let criptointercambioSDK: CriptointercambioClient + let latestTimeStamp = 0 + let offset = 0 + let firstAttempt = false + if (typeof pluginParams.settings.latestTimeStamp === 'number') { + latestTimeStamp = pluginParams.settings.latestTimeStamp + } + if ( + typeof pluginParams.settings.firstAttempt === 'undefined' || + pluginParams.settings.firstAttempt === true + ) { + firstAttempt = true + } + if ( + typeof pluginParams.settings.offset === 'number' && + firstAttempt === true + ) { + offset = pluginParams.settings.offset + } + if ( + typeof pluginParams.apiKeys.criptointercambioApiKey === 'string' && + typeof pluginParams.apiKeys.criptointercambioApiSecret === 'string' + ) { + criptointercambioSDK = new CriptointercambioClient( + pluginParams.apiKeys.criptointercambioApiKey, + pluginParams.apiKeys.criptointercambioApiSecret + ) + } else { + return { + settings: { + latestTimeStamp: latestTimeStamp + }, + transactions: [] + } + } + + const ssFormatTxs: StandardTx[] = [] + let newLatestTimeStamp = latestTimeStamp + let done = false + try { + while (!done) { + datelog(`Query criptointercambio offset: ${offset}`) + const result = await getTransactionsPromised( + criptointercambioSDK, + LIMIT, + offset, + undefined, + undefined, + undefined + ) + const txs = asCriptointercambioResult(result) + if (txs.length === 0) { + datelog(`Criptointercambio done at offset ${offset}`) + firstAttempt = false + break + } + for (const rawTx of txs) { + if (asCriptointercambioRawTx(rawTx).status === 'finished') { + const tx = asCriptointercambioTx(rawTx) + const ssTx: StandardTx = { + status: 'complete', + orderId: tx.id, + depositTxid: tx.payinHash, + depositAddress: tx.payinAddress, + depositCurrency: tx.currencyFrom.toUpperCase(), + depositAmount: parseFloat(tx.amountFrom), + payoutTxid: tx.payoutHash, + payoutAddress: tx.payoutAddress, + payoutCurrency: tx.currencyTo.toUpperCase(), + payoutAmount: parseFloat(tx.amountTo), + timestamp: tx.createdAt, + isoDate: new Date(tx.createdAt * 1000).toISOString(), + usdValue: 0, + rawTx + } + ssFormatTxs.push(ssTx) + if (tx.createdAt > newLatestTimeStamp) { + newLatestTimeStamp = tx.createdAt + } + if ( + tx.createdAt < latestTimeStamp - QUERY_LOOKBACK && + !done && + !firstAttempt + ) { + datelog( + `Criptointercambio done: date ${ + tx.createdAt + } < ${latestTimeStamp - QUERY_LOOKBACK}` + ) + done = true + } + } + } + offset += LIMIT + } + } catch (e) { + datelog(e) + } + return { + settings: { latestTimeStamp: newLatestTimeStamp, firstAttempt, offset }, + transactions: ssFormatTxs + } +} + +export const criptointercambio: PartnerPlugin = { + queryFunc: queryCriptointercambio, + pluginName: 'Criptointercambio', + pluginId: 'criptointercambio' +} diff --git a/src/queryEngine.ts b/src/queryEngine.ts index c077ba43..b52a6c54 100644 --- a/src/queryEngine.ts +++ b/src/queryEngine.ts @@ -11,6 +11,7 @@ import { bity } from './partners/bity' import { changehero } from './partners/changehero' import { changelly } from './partners/changelly' import { changenow } from './partners/changenow' +import { criptointercambio } from './partners/criptointercambio' import { exolix } from './partners/exolix' import { foxExchange } from './partners/foxExchange' import { gebo } from './partners/gebo' @@ -124,6 +125,7 @@ const partners = [ changelly, changenow, changehero, + criptointercambio, exolix, foxExchange, gebo, diff --git a/util/cic-sdk.ts b/util/cic-sdk.ts new file mode 100644 index 00000000..bf22e2de --- /dev/null +++ b/util/cic-sdk.ts @@ -0,0 +1,115 @@ +import axios, { AxiosInstance, AxiosResponse } from 'axios' +import crypto from 'crypto' + +interface Result { + result: T +} + +interface Error { + error: { + code: number + message: string + } +} + +export interface PartialCicTransaction { + id: string + payinHash: string + payoutHash: string + payinAddress: string + currencyFrom: string + amountFrom: string + payoutAddress: string + currencyTo: string + amountTo: string + createdAt: number +} + +export interface CicTransaction extends PartialCicTransaction { + trackUrl: string + type: 'fixed' | 'float' + moneyReceived: number + moneySent: number + rate: string + payinConfirmations: string + status: string + payinExtraId?: string + payinExtraIdName?: string + payoutHashLink: string + refundHashLink?: string + amountExpectedFrom: string + payoutExtraId?: string + payoutExtraIdName?: string + refundHash?: string + refundAddress: string + refundExtraId?: string + amountExpectedTo: string + networkFee: string + apiExtraFee: string + totalFee: string + canPush: boolean + canRefund: boolean +} +export class CriptointercambioClient { + private readonly client: AxiosInstance = axios.create({ + baseURL: 'https://api.criptointercambio.com/v2', + timeout: 20000 + }) + + constructor( + private readonly apiKey: string, + private readonly secret: string + ) {} + + getSigningHeaders(body: Record): Record { + const signature = crypto.sign('sha256', Buffer.from(JSON.stringify(body)), { + key: this.secret, + type: 'pkcs8', + format: 'der' + }) + return { + 'X-Api-Key': this.apiKey, + 'X-Api-Signature': signature.toString('base64') + } + } + + private async request( + method: string, + params: Record + ): Promise> { + const body = { + jsonrpc: '2.0', + id: 'cic-transactions', + method, + params + } + const headers = this.getSigningHeaders(body) + return this.client.post('/', body, { headers }) + } + + async getTransactions( + limit: number, + offset: number, + currency?: string, + address?: string, + extraId?: string + ): Promise { + const result = await this.request>( + 'getTransactions', + { + currency, + address, + extraId, + offset, + limit + } + ) + if ('error' in result.data) { + throw new Error( + `[${result.data.error.code}]: Criptointercambio error: ${result.data.error.message}` + ) + } + + return result.data.result + } +}