1 2
import fetchUri from 'node-fetch'
2 2
import * as sax from 'sax'
3 2
import BigNumber from 'bignumber.js'
4
import { AccountInfo } from '../types/accounts'
5
import { BackendInstance, BackendServices } from '../types/backend'
6

7 2
import { create as createLogger } from '../common/log'
8 2
const log = createLogger('ecb')
9

10 2
const RATES_API = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml'
11

12
export interface ECBBackendOptions {
13
  spread: number,
14
  ratesApiUrl: string,
15
  mockData: ECBAPIData
16
}
17

18
export interface ECBSaxNode {
19
  name: string,
20
  attributes: {
21
    time?: number
22
    currency?: string
23
    rate?: number
24
  }
25
}
26

27
export 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
 */
38 2
export 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 2
    this.spread = opts.spread || 0
59 2
    this.ratesApiUrl = opts.ratesApiUrl || RATES_API
60 2
    this.mockData = opts.mockData
61 2
    this.getInfo = api.getInfo
62
    // this.ratesCacheTtl = opts.ratesCacheTtl || 24 * 3600000
63

64 2
    this.rates = {}
65 2
    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 2
    if (this.mockData) {
76 2
      log.info('connect using mock data.')
77 2
      apiData = this.mockData
78
    } else {
79 0
      log.info('connect. uri=' + this.ratesApiUrl)
80 0
      let result = await fetchUri(this.ratesApiUrl)
81 0
      apiData = await parseXMLResponse(await result.text())
82
    }
83 2
    this.rates = apiData.rates
84 2
    this.rates[apiData.base] = 1
85 2
    this.currencies = Object.keys(this.rates)
86 2
    this.currencies.sort()
87 2
    log.info('data loaded. numCurrencies=' + this.currencies.length)
88
  }
89

90
  _formatAmount (amount: string) {
91 0
    return new BigNumber(amount).toFixed(2)
92
  }
93

94
  _formatAmountCeil (amount: string) {
95 0
    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 2
    const sourceInfo = this.getInfo(sourceAccount)
107 2
    const destinationInfo = this.getInfo(destinationAccount)
108

109 2
    if (!sourceInfo) {
110 0
      log.error('unable to fetch account info for source account. accountId=%s', sourceAccount)
111 0
      throw new Error('unable to fetch account info for source account. accountId=' + sourceAccount)
112
    }
113 2
    if (!destinationInfo) {
114 0
      log.error('unable to fetch account info for destination account. accountId=%s', destinationAccount)
115 0
      throw new Error('unable to fetch account info for destination account. accountId=' + destinationAccount)
116
    }
117

118 2
    const sourceCurrency = sourceInfo.assetCode
119 2
    const destinationCurrency = destinationInfo.assetCode
120

121
    // Get ratio between currencies and apply spread
122 2
    const sourceRate = this.rates[sourceCurrency]
123 2
    const destinationRate = this.rates[destinationCurrency]
124

125 2
    if (!sourceRate) {
126 0
      log.error('no rate available for source currency. currency=%s', sourceCurrency)
127 0
      throw new Error('no rate available. currency=' + sourceCurrency)
128
    }
129

130 2
    if (!destinationRate) {
131 0
      log.error('no rate available for destination currency. currency=%s', destinationCurrency)
132 0
      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 2
    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 2
    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 2
    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 2
    return Promise.resolve(undefined)
158
  }
159
}
160

161
function parseXMLResponse (data: string): Promise<ECBAPIData> {
162 0
  const parser = sax.parser(true, {})
163 0
  const apiData: ECBAPIData = { base: 'EUR', rates: {} }
164 0
  parser.onopentag = (node: ECBSaxNode) => {
165 2
    if (node.name === 'Cube' && node.attributes.time) {
166 0
      apiData.date = node.attributes.time
167
    }
168 2
    if (node.name === 'Cube' && node.attributes.currency && node.attributes.rate) {
169 0
      apiData.rates[node.attributes.currency] = node.attributes.rate
170
    }
171
  }
172 0
  return new Promise((resolve, reject) => {
173 0
    parser.onerror = reject
174 0
    parser.onend = () => resolve(apiData)
175 0
    parser.write(data).close()
176
  })
177
}

Read our documentation on viewing source code .

Loading