summaryrefslogtreecommitdiff
path: root/wallet/src/commonMain/kotlin/net/taler/lib/wallet/exchange/Exchange.kt
blob: 9a6f9e2ab4605d3ec6c7f143d706c0c65325a759 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
/*
 * This file is part of GNU Taler
 * (C) 2020 Taler Systems S.A.
 *
 * GNU Taler is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 3, or (at your option) any later version.
 *
 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
 * A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
 */

package net.taler.lib.wallet.exchange

import io.ktor.client.HttpClient
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.readText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import net.taler.lib.common.Amount
import net.taler.lib.common.Timestamp
import net.taler.lib.common.Version
import net.taler.lib.crypto.Base32Crockford
import net.taler.lib.wallet.Db
import net.taler.lib.wallet.DbFactory
import net.taler.lib.crypto.Crypto
import net.taler.lib.crypto.CryptoFactory
import net.taler.lib.wallet.crypto.Signature
import net.taler.lib.wallet.exchange.DenominationStatus.Unverified
import net.taler.lib.wallet.exchange.ExchangeUpdateReason.Initial
import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FetchKeys
import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FetchTerms
import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FetchWire
import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.FinalizeUpdate
import net.taler.lib.wallet.exchange.ExchangeUpdateStatus.Finished
import net.taler.lib.wallet.getDefaultHttpClient

internal class Exchange(
    private val crypto: Crypto = CryptoFactory.getCrypto(),
    private val signature: Signature = Signature(crypto),
    private val httpClient: HttpClient = getDefaultHttpClient(),
    // using the default Http client adds a json Accept header to each request, so we need a different one
    // because the exchange is returning XML when it doesn't exactly match a mime type.
    private val httpNoJsonClient: HttpClient = HttpClient(),
    private val db: Db = DbFactory().openDb(),
) {

    companion object {
        private val PROTOCOL_VERSION = Version(8, 0, 0)
        fun getVersionMatch(version: String) = PROTOCOL_VERSION.compare(Version.parse(version))
        fun normalizeUrl(exchangeBaseUrl: String): String {
            var url = exchangeBaseUrl
            if (!url.startsWith("http")) url = "http://$url"
            if (!url.endsWith("/")) url = "$url/"
            // TODO also remove query and hash
            return url
        }
    }

    /**
     * Update or add exchange DB entry by fetching the /keys, /wire and /terms information.
     */
    suspend fun updateFromUrl(baseUrl: String): ExchangeRecord {
        val now = Timestamp.now()
        val url = normalizeUrl(baseUrl)
        var record = db.getExchangeByBaseUrl(url) ?: ExchangeRecord(
            baseUrl = url,
            timestampAdded = now,
            updateStatus = FetchKeys,
            updateStarted = now,
            updateReason = Initial,
        ).also { db.put(it) }
        val recordBeforeUpdate = record.copy()

        record = updateKeys(record)  // TODO add denominations in transaction at the end
        record = updateWireInfo(record)
        record = updateTermsOfService(record)
        record = finalizeUpdate(record)
        db.transaction {
            val dbRecord = getExchangeByBaseUrl(record.baseUrl)
            if (dbRecord != recordBeforeUpdate) throw Error("Concurrent modification of $dbRecord")
            put(record)
        }
        return record
    }

    /**
     * Fetch the exchange's /keys and update database accordingly.
     *
     * Exceptions thrown in this method must be caught and reported in the pending operations.
     */
    internal suspend fun updateKeys(record: ExchangeRecord): ExchangeRecord {
        val keys: Keys = Keys.fetch(httpClient, record.baseUrl)
        // check if there are denominations offered
        // TODO provide more error information for catcher
        if (keys.denoms.isEmpty()) {
            throw Error("Exchange doesn't offer any denominations")
        }
        // check if the exchange version is compatible
        val versionMatch = getVersionMatch(keys.version)
        if (versionMatch == null || !versionMatch.compatible) {
            throw Error("Exchange protocol version not compatible with wallet")
        }
        val currency = keys.denoms[0].value.currency
        val newDenominations = keys.denoms.map { d ->
            getDenominationRecord(record.baseUrl, currency, d)
        }
        // update exchange details
        val details = ExchangeDetails(
            auditors = keys.auditors,
            currency = currency,
            lastUpdateTime = keys.list_issue_date,
            masterPublicKey = keys.master_public_key,
            protocolVersion = keys.version,
            signingKeys = keys.signkeys
        )
        val updatedRecord = record.copy(details = details, updateStatus = FetchWire)
        for (newDenomination in newDenominations) {
            // TODO check oldDenominations and do consistency checks
            db.put(newDenomination)
        }

        // TODO handle keys.recoup

        return updatedRecord
    }

    /**
     * Turn an exchange's denominations from /keys into [DenominationRecord]s
     *
     * Visible for testing.
     */
    internal fun getDenominationRecord(baseUrl: String, currency: String, d: Denomination): DenominationRecord {
        checkCurrency(currency, d.value)
        checkCurrency(currency, d.fee_refund)
        checkCurrency(currency, d.fee_withdraw)
        checkCurrency(currency, d.fee_refresh)
        checkCurrency(currency, d.fee_deposit)
        return d.toDenominationRecord(
            baseUrl = baseUrl,
            denomPubHash = crypto.sha512(Base32Crockford.decode(d.denom_pub)),
            isOffered = true,
            isRevoked = false,
            status = Unverified,
        )
    }

    /**
     * Fetch wire information for an exchange and store it in the database.
     */
    internal suspend fun updateWireInfo(record: ExchangeRecord): ExchangeRecord {
        if (record.updateStatus != FetchWire) {
            throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FetchWire")
        }
        if (record.details == null) throw Error("Invalid exchange state")
        val wire = Wire.fetch(httpClient, record.baseUrl)
        // check account signatures
        for (a in wire.accounts) {
            val valid = signature.verifyWireAccount(
                paytoUri = a.paytoUri,
                signature = a.masterSig,
                masterPub = record.details.masterPublicKey,
            )
            if (!valid) throw Error("Exchange wire account signature invalid")
        }
        // check fee signatures
        for (fee in wire.fees) {
            val wireMethod = fee.key
            val wireFees = fee.value
            for (wireFee in wireFees) {
                val valid = signature.verifyWireFee(
                    type = wireMethod,
                    wireFee = wireFee,
                    masterPub = record.details.masterPublicKey,
                )
                if (!valid) throw Error("Exchange wire fee signature invalid")
                checkCurrency(record.details.currency, wireFee.wireFee)
                checkCurrency(record.details.currency, wireFee.closingFee)
            }
        }
        val wireInfo = ExchangeWireInfo(
            accounts = wire.accounts.map { ExchangeBankAccount(it.paytoUri) },
            feesForType = wire.fees,
        )
        return record.copy(updateStatus = FetchTerms, wireInfo = wireInfo)
    }

    /**
     * Fetch wire information for an exchange and store it in the database.
     */
    internal suspend fun updateTermsOfService(record: ExchangeRecord): ExchangeRecord {
        if (record.updateStatus != FetchTerms) {
            throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FetchTerms")
        }
        val response: HttpResponse = httpNoJsonClient.get("${record.baseUrl}terms") {
            accept(ContentType.Text.Plain)
        }
        if (response.status != HttpStatusCode.OK) {
            throw Error("/terms response has unexpected status code (${response.status.value})")
        }
        val text = response.readText()
        val eTag = response.headers[HttpHeaders.ETag]
        return record.copy(updateStatus = FinalizeUpdate, termsOfServiceText = text, termsOfServiceLastEtag = eTag)
    }

    internal fun finalizeUpdate(record: ExchangeRecord): ExchangeRecord {
        if (record.updateStatus != FinalizeUpdate) {
            throw Error("Unexpected updateStatus: ${record.updateStatus}, expected: $FinalizeUpdate")
        }
        // TODO store an event log for this update (exchangeUpdatedEvents)
        return record.copy(updateStatus = Finished, addComplete = true)
    }

    private fun checkCurrency(currency: String, amount: Amount) {
        if (currency != amount.currency) throw Error("Expected currency $currency, but found ${amount.currency}")
    }

}