UNPKG

5.75 kBPlain TextView Raw
1import fetchUri from 'node-fetch'
2import * as sax from 'sax'
3import BigNumber from 'bignumber.js'
4import { AccountInfo } from '../types/accounts'
5import { BackendInstance, BackendServices } from '../types/backend'
6
7import { create as createLogger } from '../common/log'
8const log = createLogger('ecb')
9
10const RATES_API = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml'
11
12export interface ECBBackendOptions {
13 spread: number,
14 ratesApiUrl: string,
15 mockData: ECBAPIData
16}
17
18export interface ECBSaxNode {
19 name: string,
20 attributes: {
21 time?: number
22 currency?: string
23 rate?: number
24 }
25}
26
27export interface ECBAPIData {
28 base: string
29 date?: number
30 rates: {
31 [key: string]: number
32 }
33}
34
35/**
36 * Dummy backend that uses the ECB API for FX rates
37 */
38export default class ECBBackend implements BackendInstance {
39 protected spread: number
40 protected ratesApiUrl: string
41 protected getInfo: (accountId: string) => AccountInfo | undefined
42
43 protected rates: {
44 [key: string]: number
45 }
46 protected currencies: string[]
47 private mockData: ECBAPIData
48
49 /**
50 * Constructor.
51 *
52 * @param opts.spread The spread we will use to mark up the FX rates
53 * @param opts.ratesApiUrl The URL for querying the ECB API
54 * @param api.getInfo Method which maps account IDs to AccountInfo objects
55 * @param api.getAssetCode Method which maps account IDs to asset code
56 */
57 constructor (opts: ECBBackendOptions, api: BackendServices) {
58 this.spread = opts.spread || 0
59 this.ratesApiUrl = opts.ratesApiUrl || RATES_API
60 this.mockData = opts.mockData
61 this.getInfo = api.getInfo
62 // this.ratesCacheTtl = opts.ratesCacheTtl || 24 * 3600000
63
64 this.rates = {}
65 this.currencies = []
66 }
67
68 /**
69 * Get the rates from the API
70 *
71 * Mock data can be provided for testing purposes
72 */
73 async connect () {
74 let apiData: ECBAPIData
75 if (this.mockData) {
76 log.info('connect using mock data.')
77 apiData = this.mockData
78 } else {
79 log.info('connect. uri=' + this.ratesApiUrl)
80 let result = await fetchUri(this.ratesApiUrl)
81 apiData = await parseXMLResponse(await result.text())
82 }
83 this.rates = apiData.rates
84 this.rates[apiData.base] = 1
85 this.currencies = Object.keys(this.rates)
86 this.currencies.sort()
87 log.info('data loaded. numCurrencies=' + this.currencies.length)
88 }
89
90 _formatAmount (amount: string) {
91 return new BigNumber(amount).toFixed(2)
92 }
93
94 _formatAmountCeil (amount: string) {
95 return new BigNumber(amount).decimalPlaces(2, BigNumber.ROUND_CEIL).toFixed(2)
96 }
97
98 /**
99 * Get a rate for the given parameters.
100 *
101 * @param sourceAccount The account ID of the source account
102 * @param destinationAccount The account ID of the next hop account
103 * @returns Exchange rate with spread applied
104 */
105 async getRate (sourceAccount: string, destinationAccount: string) {
106 const sourceInfo = this.getInfo(sourceAccount)
107 const destinationInfo = this.getInfo(destinationAccount)
108
109 if (!sourceInfo) {
110 log.error('unable to fetch account info for source account. accountId=%s', sourceAccount)
111 throw new Error('unable to fetch account info for source account. accountId=' + sourceAccount)
112 }
113 if (!destinationInfo) {
114 log.error('unable to fetch account info for destination account. accountId=%s', destinationAccount)
115 throw new Error('unable to fetch account info for destination account. accountId=' + destinationAccount)
116 }
117
118 const sourceCurrency = sourceInfo.assetCode
119 const destinationCurrency = destinationInfo.assetCode
120
121 // Get ratio between currencies and apply spread
122 const sourceRate = this.rates[sourceCurrency]
123 const destinationRate = this.rates[destinationCurrency]
124
125 if (!sourceRate) {
126 log.error('no rate available for source currency. currency=%s', sourceCurrency)
127 throw new Error('no rate available. currency=' + sourceCurrency)
128 }
129
130 if (!destinationRate) {
131 log.error('no rate available for destination currency. currency=%s', destinationCurrency)
132 throw new Error('no rate available. currency=' + destinationCurrency)
133 }
134
135 // The spread is subtracted from the rate when going in either direction,
136 // so that the DestinationAmount always ends up being slightly less than
137 // the (equivalent) SourceAmount -- regardless of which of the 2 is fixed:
138 //
139 // SourceAmount * Rate * (1 - Spread) = DestinationAmount
140 //
141 const rate = new BigNumber(destinationRate).shiftedBy(destinationInfo.assetScale)
142 .div(new BigNumber(sourceRate).shiftedBy(sourceInfo.assetScale))
143 .times(new BigNumber(1).minus(this.spread))
144 .toPrecision(15)
145
146 log.trace('quoted rate. from=%s to=%s fromCur=%s toCur=%s rate=%s spread=%s', sourceAccount, destinationAccount, sourceCurrency, destinationCurrency, rate, this.spread)
147
148 return Number(rate)
149 }
150
151 /**
152 * This method is called to allow statistics to be collected by the backend.
153 *
154 * The ECB backend does not support this functionality.
155 */
156 async submitPayment () {
157 return Promise.resolve(undefined)
158 }
159}
160
161function parseXMLResponse (data: string): Promise<ECBAPIData> {
162 const parser = sax.parser(true, {})
163 const apiData: ECBAPIData = { base: 'EUR', rates: {} }
164 parser.onopentag = (node: ECBSaxNode) => {
165 if (node.name === 'Cube' && node.attributes.time) {
166 apiData.date = node.attributes.time
167 }
168 if (node.name === 'Cube' && node.attributes.currency && node.attributes.rate) {
169 apiData.rates[node.attributes.currency] = node.attributes.rate
170 }
171 }
172 return new Promise((resolve, reject) => {
173 parser.onerror = reject
174 parser.onend = () => resolve(apiData)
175 parser.write(data).close()
176 })
177}