libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

commit b2b741902e930adc9f0cfca8d29f78b9cc628ecc
parent 6c8bacf30bb7e0acbbe0a9eaffee83b31c93b088
Author: Antoine A <>
Date:   Thu, 12 Oct 2023 02:38:44 +0000

Cleanup and fixes

Diffstat:
Dbank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt | 871-------------------------------------------------------------------------------
Mbank/src/main/kotlin/tech/libeufin/bank/Main.kt | 148+++----------------------------------------------------------------------------
Abank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt | 358+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt | 703+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbank/src/main/kotlin/tech/libeufin/bank/helpers.kt | 89+++----------------------------------------------------------------------------
Mbank/src/test/kotlin/AmountTest.kt | 50+++++++++++++++++++++++++++++++++-----------------
Mbank/src/test/kotlin/helpers.kt | 10+++++++++-
7 files changed, 1110 insertions(+), 1119 deletions(-)

diff --git a/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt b/bank/src/main/kotlin/tech/libeufin/bank/BankMessages.kt @@ -1,870 +0,0 @@ -/* - * This file is part of LibEuFin. - * Copyright (C) 2019 Stanisci and Dold. - - * LibEuFin is free software; you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation; either version 3, or - * (at your option) any later version. - - * LibEuFin 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 Affero General - * Public License for more details. - - * You should have received a copy of the GNU Affero General Public - * License along with LibEuFin; see the file COPYING. If not, see - * <http://www.gnu.org/licenses/> - */ - -package tech.libeufin.bank - -import io.ktor.http.* -import io.ktor.server.application.* -import kotlinx.serialization.Serializable -import net.taler.wallet.crypto.Base32Crockford -import net.taler.wallet.crypto.EncodingException -import java.time.Duration -import java.time.Instant -import java.time.temporal.ChronoUnit -import java.util.* -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.* -import kotlinx.serialization.encoding.* - -/** - * 32-byte Crockford's Base32 encoded data. - */ -@Serializable(with = Base32Crockford32B.Serializer::class) -class Base32Crockford32B { - private var encoded: String? = null - val raw: ByteArray - - constructor(encoded: String) { - val decoded = try { - Base32Crockford.decode(encoded) - } catch (e: EncodingException) { - null - } - - require(decoded != null) { - "Data should be encoded using Crockford's Base32" - } - require(decoded.size == 32) { - "Encoded data should be 32 bytes long" - } - this.raw = decoded - this.encoded = encoded - } - constructor(raw: ByteArray) { - require(raw.size == 32) { - "Encoded data should be 32 bytes long" - } - this.raw = raw - } - - fun encoded(): String { - encoded = encoded ?: Base32Crockford.encode(raw) - return encoded!! - } - - override fun equals(other: Any?) = (other is Base32Crockford32B) && Arrays.equals(raw, other.raw) - - internal object Serializer : KSerializer<Base32Crockford32B> { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford32B", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Base32Crockford32B) { - encoder.encodeString(value.encoded()) - } - - override fun deserialize(decoder: Decoder): Base32Crockford32B { - return Base32Crockford32B(decoder.decodeString()) - } - } -} - -/** - * 64-byte Crockford's Base32 encoded data. - */ -@Serializable(with = Base32Crockford64B.Serializer::class) -class Base32Crockford64B { - private var encoded: String? = null - val raw: ByteArray - - constructor(encoded: String) { - val decoded = try { - Base32Crockford.decode(encoded) - } catch (e: EncodingException) { - null - } - - require(decoded != null) { - "Data should be encoded using Crockford's Base32" - } - require(decoded.size == 64) { - "Encoded data should be 32 bytes long" - } - this.raw = decoded - this.encoded = encoded - } - constructor(raw: ByteArray) { - require(raw.size == 64) { - "Encoded data should be 32 bytes long" - } - this.raw = raw - } - - fun encoded(): String { - encoded = encoded ?: Base32Crockford.encode(raw) - return encoded!! - } - - override fun equals(other: Any?) = (other is Base32Crockford64B) && Arrays.equals(raw, other.raw) - - internal object Serializer : KSerializer<Base32Crockford64B> { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford64B", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Base32Crockford64B) { - encoder.encodeString(value.encoded()) - } - - override fun deserialize(decoder: Decoder): Base32Crockford64B { - return Base32Crockford64B(decoder.decodeString()) - } - } -} - -/** 32-byte hash code. */ -typealias ShortHashCode = Base32Crockford32B; -/** 64-byte hash code. */ -typealias HashCode = Base32Crockford64B; -/** - * EdDSA and ECDHE public keys always point on Curve25519 - * and represented using the standard 256 bits Ed25519 compact format, - * converted to Crockford Base32. - */ -typealias EddsaPublicKey = Base32Crockford32B; - -/** - * Allowed lengths for fractional digits in amounts. - */ -enum class FracDigits { - TWO, EIGHT -} - -/** - * Timestamp containing the number of seconds since epoch. - */ -@Serializable(with = TalerProtocolTimestampSerializer::class) -data class TalerProtocolTimestamp( - val t_s: Instant, -) { - companion object { - fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp { - return TalerProtocolTimestamp( - Instant.EPOCH.plus(uSec, ChronoUnit.MICROS) - ) - } - } -} - -/** - * HTTP response type of successful token refresh. - * access_token is the Crockford encoding of the 32 byte - * access token, whereas 'expiration' is the point in time - * when this token expires. - */ -@Serializable -data class TokenSuccessResponse( - val access_token: String, - val expiration: TalerProtocolTimestamp -) - -/** - * Error object to respond to the client. The - * 'code' field takes values from the GANA gnu-taler-error-code - * specification. 'hint' is a human-readable description - * of the error. - */ -@Serializable -data class TalerError( - val code: Int, - val hint: String? = null, - val detail: String? = null -) - -/* Contains contact data to send TAN challges to the -* users, to let them complete cashout operations. */ -@Serializable -data class ChallengeContactData( - val email: String? = null, - val phone: String? = null -) - -// Type expected at POST /accounts -@Serializable -data class RegisterAccountRequest( - val username: String, - val password: String, - val name: String, - val is_public: Boolean = false, - val is_taler_exchange: Boolean = false, - val challenge_contact_data: ChallengeContactData? = null, - // External bank account where to send cashout amounts. - val cashout_payto_uri: String? = null, - // Bank account internal to Libeufin-Bank. - val internal_payto_uri: String? = null -) - -/** - * Internal representation of relative times. The - * "forever" case is represented with Long.MAX_VALUE. - */ -@Serializable(with = RelativeTimeSerializer::class) -data class RelativeTime( - val d_us: Duration -) - -/** - * Type expected at POST /accounts/{USERNAME}/token - * It complies with Taler's design document #49 - */ -@Serializable -data class TokenRequest( - val scope: TokenScope, - val duration: RelativeTime? = null, - val refreshable: Boolean = false -) - -/** - * Convenience type to throw errors along the bank activity - * and that is meant to be caught by Ktor and responded to the - * client. - */ -class LibeufinBankException( - // Status code that Ktor will set for the response. - val httpStatus: HttpStatusCode, - // Error detail object, after Taler API. - val talerError: TalerError -) : Exception(talerError.hint) - -/** - * Convenience type to hold customer data, typically after such - * data gets fetched from the database. It is also used to _insert_ - * customer data to the database. - */ -data class Customer( - val login: String, - val passwordHash: String, - val name: String, - /** - * Only non-null when this object is defined _by_ the - * database. - */ - val dbRowId: Long? = null, - val email: String? = null, - val phone: String? = null, - /** - * External bank account where customers send - * their cashout amounts. - */ - val cashoutPayto: String? = null, - /** - * Currency of the external bank account where - * customers send their cashout amounts. - */ - val cashoutCurrency: String? = null -) - -/** - * Represents a Taler amount. This type can be used both - * to hold database records and amounts coming from the parser. - * If maybeCurrency is null, then the constructor defaults it - * to be the "internal currency". Internal currency is the one - * with which Libeufin-Bank moves funds within itself, therefore - * not to be mistaken with the cashout currency, which is the one - * that gets credited to Libeufin-Bank users to their cashout_payto_uri. - * - * maybeCurrency is typically null when the TalerAmount object gets - * defined by the Database class. - */ -@Serializable(with = TalerAmountSerializer::class) -class TalerAmount( - val value: Long, - val frac: Int, - val currency: String -) { - override fun equals(other: Any?): Boolean { - return other is TalerAmount && - other.value == this.value && - other.frac == this.frac && - other.currency == this.currency - } - - override fun toString(): String { - val fracNoTrailingZero = this.frac.toString().dropLastWhile { it == '0' } - if (fracNoTrailingZero.isEmpty()) return "$currency:$value" - return "$currency:$value.$fracNoTrailingZero" - } -} - -/** - * Convenience type to get and set bank account information - * from/to the database. - */ -data class BankAccount( - val internalPaytoUri: String, - // Database row ID of the customer that owns this bank account. - val owningCustomerId: Long, - val bankAccountId: Long? = null, // null at INSERT. - val isPublic: Boolean = false, - val isTalerExchange: Boolean = false, - /** - * Because bank accounts MAY be funded by an external currency, - * local bank accounts need to query Nexus, in order to find this - * out. This field is a pointer to the latest incoming payment that - * was contained in a Nexus history response. - * - * Typically, the 'admin' bank account uses this field, in order - * to initiate Taler withdrawals that depend on an external currency - * being wired by wallet owners. - */ - val lastNexusFetchRowId: Long = 0L, - val balance: TalerAmount? = null, // null when a new bank account gets created. - val hasDebt: Boolean, - val maxDebt: TalerAmount -) - -// Allowed values for bank transactions directions. -enum class TransactionDirection { - credit, - debit -} - -// Allowed values for cashout TAN channels. -enum class TanChannel { - sms, - email, - file // Writes cashout TANs to /tmp, for testing. -} - -// Scopes for authentication tokens. -enum class TokenScope { - readonly, - readwrite, - refreshable // Not spec'd as a scope! -} - -/** - * Convenience type to set/get authentication tokens to/from - * the database. - */ -data class BearerToken( - val content: ByteArray, - val scope: TokenScope, - val isRefreshable: Boolean = false, - val creationTime: Instant, - val expirationTime: Instant, - /** - * Serial ID of the database row that hosts the bank customer - * that is associated with this token. NOTE: if the token is - * refreshed by a client that doesn't have a user+password login - * in the system, the creator remains always the original bank - * customer that created the very first token. - */ - val bankCustomer: Long -) - -/** - * Convenience type to _communicate_ a bank transfer to the - * database procedure, NOT representing therefore any particular - * table. The procedure will then retrieve all the tables data - * from this type. - */ -data class BankInternalTransaction( - // Database row ID of the internal bank account sending the payment. - val creditorAccountId: Long, - // Database row ID of the internal bank account receiving the payment. - val debtorAccountId: Long, - val subject: String, - val amount: TalerAmount, - val transactionDate: Instant, - val accountServicerReference: String = "not used", // ISO20022 - val endToEndId: String = "not used", // ISO20022 - val paymentInformationId: String = "not used" // ISO20022 -) - -/** - * Convenience type representing bank transactions as they - * are in the respective database table. Only used to _get_ - * the information from the database. - */ -data class BankAccountTransaction( - val creditorPaytoUri: String, - val creditorName: String, - val debtorPaytoUri: String, - val debtorName: String, - val subject: String, - val amount: TalerAmount, - val transactionDate: Instant, - /** - * Is the transaction debit, or credit for the - * bank account pointed by this object? - */ - val direction: TransactionDirection, - /** - * database row ID of the bank account that is - * impacted by the direction. For example, if the - * direction is debit, then this value points to the - * bank account of the payer. - */ - val bankAccountId: Long, - // Null if this type is used to _create_ one transaction. - val dbRowId: Long? = null, - // Following are ISO20022 specific. - val accountServicerReference: String, - val paymentInformationId: String, - val endToEndId: String, -) - -/** - * Represents a Taler withdrawal operation, as it is - * stored in the respective database table. - */ -data class TalerWithdrawalOperation( - val withdrawalUuid: UUID, - val amount: TalerAmount, - val selectionDone: Boolean = false, - val aborted: Boolean = false, - val confirmationDone: Boolean = false, - val reservePub: String?, - val selectedExchangePayto: String?, - val walletBankAccount: Long -) - -/** - * Represents a cashout operation, as it is stored - * in the respective database table. - */ -data class Cashout( - val cashoutUuid: UUID, - val localTransaction: Long? = null, - val amountDebit: TalerAmount, - val amountCredit: TalerAmount, - val buyAtRatio: Int, - val buyInFee: TalerAmount, - val sellAtRatio: Int, - val sellOutFee: TalerAmount, - val subject: String, - val creationTime: Instant, - val tanConfirmationTime: Instant? = null, - val tanChannel: TanChannel, - val tanCode: String, - val bankAccount: Long, - val credit_payto_uri: String, - val cashoutCurrency: String -) - -// Type to return as GET /config response -@Serializable // Never used to parse JSON. -data class Config( - val currency: CurrencySpecification, -) { - val name: String = "libeufin-bank" - val version: String = "0:0:0" - val have_cashout: Boolean = false - // Following might probably get renamed: - val fiat_currency: String? = null -} - -enum class CorebankCreditDebitInfo { - credit, debit -} - -@Serializable -data class Balance( - val amount: TalerAmount, - val credit_debit_indicator: CorebankCreditDebitInfo, -) - -/** - * GET /accounts response. - */ -@Serializable -data class AccountMinimalData( - val username: String, - val name: String, - val balance: Balance, - val debit_threshold: TalerAmount -) - -/** - * Response type of GET /accounts. - */ -@Serializable -data class ListBankAccountsResponse( - val accounts: MutableList<AccountMinimalData> = mutableListOf() -) - -/** - * GET /accounts/$USERNAME response. - */ -@Serializable -data class AccountData( - val name: String, - val balance: Balance, - val payto_uri: String, - val debit_threshold: TalerAmount, - val contact_data: ChallengeContactData? = null, - val cashout_payto_uri: String? = null, -) - -/** - * Response type of corebank API transaction initiation. - */ -@Serializable -data class BankAccountTransactionCreate( - val payto_uri: String, - val amount: TalerAmount -) - -/* History element, either from GET /transactions/T_ID - or from GET /transactions */ -@Serializable -data class BankAccountTransactionInfo( - val creditor_payto_uri: String, - val debtor_payto_uri: String, - val amount: TalerAmount, - val direction: TransactionDirection, - val subject: String, - val row_id: Long, // is T_ID - val date: TalerProtocolTimestamp -) - -// Response type for histories, namely GET /transactions -@Serializable -data class BankAccountTransactionsResponse( - val transactions: List<BankAccountTransactionInfo> -) - -// Taler withdrawal request. -@Serializable -data class BankAccountCreateWithdrawalRequest( - val amount: TalerAmount -) - -// Taler withdrawal response. -@Serializable -data class BankAccountCreateWithdrawalResponse( - val withdrawal_id: String, - val taler_withdraw_uri: String -) - -// Taler withdrawal details response -@Serializable -data class BankAccountGetWithdrawalResponse( - val amount: TalerAmount, - val aborted: Boolean, - val confirmation_done: Boolean, - val selection_done: Boolean, - val selected_reserve_pub: String? = null, - val selected_exchange_account: String? = null -) - -typealias ResourceName = String - -/** - * Checks if the input Customer has the rights over ResourceName. - */ -fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { - if (c.login == this) return true - if (c.login == "admin" && withAdmin) return true - return false -} - -/** - * Factors out the retrieval of the resource name from - * the URI. The resource looked for defaults to "USERNAME" - * as this is frequently mentioned resource along the endpoints. - * - * This helper is recommended because it returns a ResourceName - * type that then offers the ".canI()" helper to check if the user - * has the rights on the resource. - */ -fun ApplicationCall.getResourceName(param: String): ResourceName = - this.expectUriComponent(param) - -/** - * This type communicates the result of deleting an account - * from the database. - */ -enum class CustomerDeletionResult { - SUCCESS, - CUSTOMER_NOT_FOUND, - BALANCE_NOT_ZERO -} - -/** - * This type communicates the result of a database operation - * to confirm one withdrawal operation. - */ -enum class WithdrawalConfirmationResult { - SUCCESS, - OP_NOT_FOUND, - EXCHANGE_NOT_FOUND, - BALANCE_INSUFFICIENT, - - /** - * This state indicates that the withdrawal was already - * confirmed BUT Kotlin did not detect it and still invoked - * the SQL procedure to confirm the withdrawal. This is - * conflictual because only Kotlin is responsible to check - * for idempotency, and this state witnesses a failure in - * this regard. - */ - CONFLICT -} - -/** - * Communicates the result of creating a bank transaction in the database. - */ -enum class BankTransactionResult { - NO_CREDITOR, - NO_DEBTOR, - SUCCESS, - CONFLICT // balance insufficient -} - -// GET /config response from the Taler Integration API. -@Serializable -data class TalerIntegrationConfigResponse( - val currency: String, - val currency_specification: CurrencySpecification, -) { - val name: String = "taler-bank-integration"; - val version: String = "0:0:0"; -} - -@Serializable -data class CurrencySpecification( - val name: String, - val decimal_separator: String, - val num_fractional_input_digits: Int, - val num_fractional_normal_digits: Int, - val num_fractional_trailing_zero_digits: Int, - val is_currency_name_leading: Boolean, - val alt_unit_names: Map<String, String> -) - -/** - * Withdrawal status as specified in the Taler Integration API. - */ -@Serializable -data class BankWithdrawalOperationStatus( - // Indicates whether the withdrawal was aborted. - val aborted: Boolean, - - /* Has the wallet selected parameters for the withdrawal operation - (exchange and reserve public key) and successfully sent it - to the bank? */ - val selection_done: Boolean, - - /* The transfer has been confirmed and registered by the bank. - Does not guarantee that the funds have arrived at the exchange - already. */ - val transfer_done: Boolean, - - /* Amount that will be withdrawn with this operation - (raw amount without fee considerations). */ - val amount: TalerAmount, - - /* Bank account of the customer that is withdrawing, as a - ``payto`` URI. */ - val sender_wire: String? = null, - - // Suggestion for an exchange given by the bank. - val suggested_exchange: String? = null, - - /* URL that the user needs to navigate to in order to - complete some final confirmation (e.g. 2FA). - It may contain withdrawal operation id */ - val confirm_transfer_url: String? = null, - - // Wire transfer types supported by the bank. - val wire_types: MutableList<String> = mutableListOf("iban") -) - -/** - * Selection request on a Taler withdrawal. - */ -@Serializable -data class BankWithdrawalOperationPostRequest( - val reserve_pub: String, - val selected_exchange: String, -) - -/** - * Response to the wallet after it selects the exchange - * and the reserve pub. - */ -@Serializable -data class BankWithdrawalOperationPostResponse( - val transfer_done: Boolean, - val confirm_transfer_url: String? = null -) - -/** - * Request to an /admin/add-incoming request from - * the Taler Wire Gateway API. - */ -@Serializable -data class AddIncomingRequest( - val amount: TalerAmount, - val reserve_pub: EddsaPublicKey, - val debit_account: String -) - -/** - * Response to /admin/add-incoming - */ -@Serializable -data class AddIncomingResponse( - val timestamp: TalerProtocolTimestamp, - val row_id: Long -) - -@Serializable -data class TWGConfigResponse( - val name: String = "taler-wire-gateway", - val version: String = "0:0:0", - val currency: String -) - -/** - * Response of a TWG /history/incoming call. - */ -@Serializable -data class IncomingHistory( - val incoming_transactions: List<IncomingReserveTransaction>, - val credit_account: String // Receiver's Payto URI. -) - -/** - * TWG's incoming payment record. - */ -@Serializable -data class IncomingReserveTransaction( - val type: String = "RESERVE", - val row_id: Long, // DB row ID of the payment. - val date: TalerProtocolTimestamp, - val amount: TalerAmount, - val debit_account: String, // Payto of the sender. - val reserve_pub: EddsaPublicKey -) - -/** - * Response of a TWG /history/outgoing call. - */ -@Serializable -data class OutgoingHistory( - val outgoing_transactions: List<OutgoingTransaction>, - val debit_account: String // Debitor's Payto URI. -) - -/** - * TWG's outgoinf payment record. - */ -@Serializable -data class OutgoingTransaction( - val row_id: Long, // DB row ID of the payment. - val date: TalerProtocolTimestamp, - val amount: TalerAmount, - val credit_account: String, // Payto of the receiver. - val wtid: ShortHashCode, - val exchange_base_url: String, -) - -/** - * TWG's request to pay a merchant. - */ -@Serializable -data class TransferRequest( - val request_uid: HashCode, - val amount: TalerAmount, - val exchange_base_url: String, - val wtid: ShortHashCode, - val credit_account: String -) - -/** - * TWG's response to merchant payouts - */ -@Serializable -data class TransferResponse( - val timestamp: TalerProtocolTimestamp, - val row_id: Long -) - -/** - * Response to GET /public-accounts - */ -@Serializable -data class PublicAccountsResponse( - val public_accounts: MutableList<PublicAccount> = mutableListOf() -) - -/** - * Single element of GET /public-accounts list. - */ -@Serializable -data class PublicAccount( - val payto_uri: String, - val balance: Balance, - val account_name: String -) - -/** - * Request of PATCH /accounts/{USERNAME}/auth - */ -@Serializable -data class AccountPasswordChange( - val new_password: String -) - -/** - * Request of PATCH /accounts/{USERNAME} - */ -@Serializable -data class AccountReconfiguration( - val challenge_contact_data: ChallengeContactData?, - val cashout_address: String?, - val name: String?, - val is_exchange: Boolean? -) - -/** - * This type expresses the outcome of updating the account - * data in the database. - */ -enum class AccountReconfigDBResult { - /** - * This indicates that despite the customer row was - * found in the database, its related bank account was not. - * This condition is a hard failure of the bank, since - * every customer must have one (and only one) bank account. - */ - BANK_ACCOUNT_NOT_FOUND, - - /** - * The customer row wasn't found in the database. This error - * should be rare, as the client got authenticated in the first - * place, before the handler could try the reconfiguration in - * the database. - */ - CUSTOMER_NOT_FOUND, - - /** - * Reconfiguration successful. - */ - SUCCESS -} -\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/Main.kt b/bank/src/main/kotlin/tech/libeufin/bank/Main.kt @@ -163,149 +163,6 @@ data class BankApplicationContext( } } - -/** - * This custom (de)serializer interprets the Timestamp JSON - * type of the Taler common API. In particular, it is responsible - * for _serializing_ timestamps, as this datatype is so far - * only used to respond to clients. - */ -object TalerProtocolTimestampSerializer : KSerializer<TalerProtocolTimestamp> { - override fun serialize(encoder: Encoder, value: TalerProtocolTimestamp) { - // Thanks: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#hand-written-composite-serializer - encoder.encodeStructure(descriptor) { - if (value.t_s == Instant.MAX) { - encodeStringElement(descriptor, 0, "never") - return@encodeStructure - } - encodeLongElement(descriptor, 0, value.t_s.epochSecond) - } - } - - override fun deserialize(decoder: Decoder): TalerProtocolTimestamp { - val jsonInput = - decoder as? JsonDecoder ?: throw internalServerError("TalerProtocolTimestamp had no JsonDecoder") - val json = try { - jsonInput.decodeJsonElement().jsonObject - } catch (e: Exception) { - throw badRequest( - "Did not find a JSON object for TalerProtocolTimestamp: ${e.message}", - TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID - ) - } - val maybeTs = json["t_s"]?.jsonPrimitive ?: throw badRequest("Taler timestamp invalid: t_s field not found") - if (maybeTs.isString) { - if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found") - return TalerProtocolTimestamp(t_s = Instant.MAX) - } - val ts: Long = maybeTs.longOrNull - ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number") - // Not allowing negative values, despite java.time allowance. - if (ts < 0) - throw badRequest("Negative timestamp not allowed.") - val instant = try { - Instant.ofEpochSecond(ts) - } catch (e: Exception) { - logger.error("Could not get Instant from t_s: $ts: ${e.message}") - throw badRequest("Could not serialize this t_s: ${ts}") - } - return TalerProtocolTimestamp(instant) - } - - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("TalerProtocolTimestamp") { - element<JsonElement>("t_s") - } -} - -/** - * This custom (de)serializer interprets the RelativeTime JSON - * type. In particular, it is responsible for converting the - * "forever" string into Long.MAX_VALUE. Any other numeric value - * is passed as is. - */ -object RelativeTimeSerializer : KSerializer<RelativeTime> { - /** - * Internal representation to JSON. - */ - override fun serialize(encoder: Encoder, value: RelativeTime) { - // Thanks: https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/serializers.md#hand-written-composite-serializer - encoder.encodeStructure(descriptor) { - if (value.d_us == ChronoUnit.FOREVER.duration) { - encodeStringElement(descriptor, 0, "forever") - return@encodeStructure - } - val dUs = try { - value.d_us.toNanos() / 1000L - } catch (e: Exception) { - logger.error(e.message) - // Bank's fault, as each numeric value should be checked before entering the system. - throw internalServerError("Could not convert java.time.Duration to JSON") - } - encodeLongElement(descriptor, 0, dUs) - } - } - - /** - * JSON to internal representation. - */ - override fun deserialize(decoder: Decoder): RelativeTime { - val jsonInput = decoder as? JsonDecoder ?: throw internalServerError("RelativeTime had no JsonDecoder") - val json = try { - jsonInput.decodeJsonElement().jsonObject - } catch (e: Exception) { - throw badRequest( - "Did not find a RelativeTime JSON object: ${e.message}", - TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID - ) - } - val maybeDUs = json["d_us"]?.jsonPrimitive ?: throw badRequest("Relative time invalid: d_us field not found") - if (maybeDUs.isString) { - if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found") - return RelativeTime(d_us = ChronoUnit.FOREVER.duration) - } - val dUs: Long = maybeDUs.longOrNull - ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") - if (dUs < 0) - throw badRequest("Negative duration specified.") - val duration = try { - Duration.of(dUs, ChronoUnit.MICROS) - } catch (e: Exception) { - logger.error("Could not get Duration out of d_us content: ${dUs}. ${e.message}") - throw badRequest("Could not get Duration out of d_us content: ${dUs}") - } - return RelativeTime(d_us = duration) - } - - override val descriptor: SerialDescriptor = - buildClassSerialDescriptor("RelativeTime") { - // JsonElement helps to obtain "union" type Long|String - element<JsonElement>("d_us") - } -} - -object TalerAmountSerializer : KSerializer<TalerAmount> { - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: TalerAmount) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): TalerAmount { - val maybeAmount = try { - decoder.decodeString() - } catch (e: Exception) { - throw badRequest( - "Did not find any Taler amount as string: ${e.message}", - TalerErrorCode.TALER_EC_GENERIC_JSON_INVALID - ) - } - return parseTalerAmount(maybeAmount) - } -} - /** * This plugin inflates the requests that have "Content-Encoding: deflate" */ @@ -511,8 +368,11 @@ fun durationFromPretty(s: String): Long { fun TalerConfig.requireAmount(section: String, option: String, currency: String): TalerAmount { val amountStr = lookupString(section, option) ?: throw TalerConfigError("expected amount for section $section, option $option, but config value is empty") - val amount = parseTalerAmount2(amountStr, FracDigits.EIGHT) ?: + val amount = try { + TalerAmount(amountStr) + } catch (e: Exception) { throw TalerConfigError("expected amount for section $section, option $option, but amount is malformed") + } if (amount.currency != currency) { throw TalerConfigError( diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerCommon.kt @@ -0,0 +1,357 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2019 Stanisci and Dold. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import kotlinx.serialization.Serializable +import net.taler.wallet.crypto.Base32Crockford +import net.taler.wallet.crypto.EncodingException +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit +import java.util.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.* +import net.taler.common.errorcodes.TalerErrorCode +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.slf4j.event.Level + +private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.TalerCommon") +const val MAX_SAFE_INTEGER = 9007199254740991L; // 2^53 - 1 + +/** + * 32-byte Crockford's Base32 encoded data. + */ +@Serializable(with = Base32Crockford32B.Serializer::class) +class Base32Crockford32B { + private var encoded: String? = null + val raw: ByteArray + + constructor(encoded: String) { + val decoded = try { + Base32Crockford.decode(encoded) + } catch (e: EncodingException) { + null + } + + require(decoded != null) { + "Data should be encoded using Crockford's Base32" + } + require(decoded.size == 32) { + "Encoded data should be 32 bytes long" + } + this.raw = decoded + this.encoded = encoded + } + constructor(raw: ByteArray) { + require(raw.size == 32) { + "Encoded data should be 32 bytes long" + } + this.raw = raw + } + + fun encoded(): String { + encoded = encoded ?: Base32Crockford.encode(raw) + return encoded!! + } + + override fun toString(): String { + return encoded() + } + + override fun equals(other: Any?) = (other is Base32Crockford32B) && Arrays.equals(raw, other.raw) + + internal object Serializer : KSerializer<Base32Crockford32B> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford32B", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Base32Crockford32B) { + encoder.encodeString(value.encoded()) + } + + override fun deserialize(decoder: Decoder): Base32Crockford32B { + return Base32Crockford32B(decoder.decodeString()) + } + } +} + +/** + * 64-byte Crockford's Base32 encoded data. + */ +@Serializable(with = Base32Crockford64B.Serializer::class) +class Base32Crockford64B { + private var encoded: String? = null + val raw: ByteArray + + constructor(encoded: String) { + val decoded = try { + Base32Crockford.decode(encoded) + } catch (e: EncodingException) { + null + } + + require(decoded != null) { + "Data should be encoded using Crockford's Base32" + } + require(decoded.size == 64) { + "Encoded data should be 32 bytes long" + } + this.raw = decoded + this.encoded = encoded + } + constructor(raw: ByteArray) { + require(raw.size == 64) { + "Encoded data should be 32 bytes long" + } + this.raw = raw + } + + fun encoded(): String { + encoded = encoded ?: Base32Crockford.encode(raw) + return encoded!! + } + + override fun toString(): String { + return encoded() + } + + override fun equals(other: Any?) = (other is Base32Crockford64B) && Arrays.equals(raw, other.raw) + + internal object Serializer : KSerializer<Base32Crockford64B> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Base32Crockford64B", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Base32Crockford64B) { + encoder.encodeString(value.encoded()) + } + + override fun deserialize(decoder: Decoder): Base32Crockford64B { + return Base32Crockford64B(decoder.decodeString()) + } + } +} + +/** 32-byte hash code. */ +typealias ShortHashCode = Base32Crockford32B; +/** 64-byte hash code. */ +typealias HashCode = Base32Crockford64B; +/** + * EdDSA and ECDHE public keys always point on Curve25519 + * and represented using the standard 256 bits Ed25519 compact format, + * converted to Crockford Base32. + */ +typealias EddsaPublicKey = Base32Crockford32B; + +/** + * Timestamp containing the number of seconds since epoch. + */ +@Serializable +data class TalerProtocolTimestamp( + @Serializable(with = TalerProtocolTimestamp.Serializer::class) + val t_s: Instant, +) { + companion object { + fun fromMicroseconds(uSec: Long): TalerProtocolTimestamp { + return TalerProtocolTimestamp( + Instant.EPOCH.plus(uSec, ChronoUnit.MICROS) + ) + } + } + + internal object Serializer : KSerializer<Instant> { + override fun serialize(encoder: Encoder, value: Instant) { + if (value == Instant.MAX) { + encoder.encodeString("never") + } else { + encoder.encodeLong(value.epochSecond) + } + + } + + override fun deserialize(decoder: Decoder): Instant { + val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + val maybeTs = jsonInput.decodeJsonElement().jsonPrimitive + if (maybeTs.isString) { + if (maybeTs.content != "never") throw badRequest("Only 'never' allowed for t_s as string, but '${maybeTs.content}' was found") + return Instant.MAX + } + val ts: Long = maybeTs.longOrNull + ?: throw badRequest("Could not convert t_s '${maybeTs.content}' to a number") + when { + ts < 0 -> throw badRequest("Negative timestamp not allowed") + ts > Instant.MAX.getEpochSecond() -> throw badRequest("Timestamp $ts too big to be represented in Kotlin") + else -> return Instant.ofEpochSecond(ts) + } + } + + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + } +} + +/** + * Represents a Taler amount. This type can be used both + * to hold database records and amounts coming from the parser. + * If maybeCurrency is null, then the constructor defaults it + * to be the "internal currency". Internal currency is the one + * with which Libeufin-Bank moves funds within itself, therefore + * not to be mistaken with the cashout currency, which is the one + * that gets credited to Libeufin-Bank users to their cashout_payto_uri. + * + * maybeCurrency is typically null when the TalerAmount object gets + * defined by the Database class. + */ +@Serializable(with = TalerAmount.Serializer::class) +class TalerAmount { + val value: Long + val frac: Int + val currency: String + + constructor(value: Long, frac: Int, currency: String) { + this.value = value + this.frac = frac + this.currency = currency + } + constructor(encoded: String) { + fun badAmount(hint: String): Exception = + badRequest(hint, TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT) + + if (encoded.isEmpty()) throw badAmount("Empty amount") + val currencySplit: List<String> = encoded.split(':', limit = 2) + if (currencySplit.size != 2) throw badAmount("Missing value") + currency = currencySplit[0].trimStart() + if (currency.length > 12) throw badAmount("Currency too big") + val dotSplit: List<String> = currencySplit[1].split('.', limit = 2) + + value = dotSplit[0].toLongOrNull() ?: throw badAmount("Invalid value") + if (value > MAX_SAFE_INTEGER) throw badAmount("Value specified in amount is too large") + + if (dotSplit.size == 2) { + if (dotSplit[1].length > 8) throw badAmount("Fractional value too precise") + var tmp: Int = dotSplit[1].toIntOrNull() ?: throw badAmount("Invalid fractional value") + repeat(8 - dotSplit[1].length) { + tmp *= 10 + } + frac = tmp + } else { + frac = 0 + } + } + + fun normalize(): TalerAmount { + if (frac > FRACTION_BASE) { + val overflow = frac / FRACTION_BASE + val normalFrac = frac % FRACTION_BASE + val normalValue = value + overflow + if (normalValue < overflow || normalValue > MAX_SAFE_INTEGER) + throw badRequest("Amount value overflowed") + return TalerAmount( + value = normalValue, frac = normalFrac, currency = currency + ) + } + return this + } + + operator fun plus(other: TalerAmount): TalerAmount { + if (currency != other.currency) throw badRequest( + "Currency mismatch, balance '$currency', price '${other.currency}'", + TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH + ) + val valueAdd = value + other.value + if (valueAdd < value || valueAdd > MAX_SAFE_INTEGER) throw badRequest("Amount value overflowed") + val fracAdd = frac + other.frac + if (fracAdd < frac) throw badRequest("Amount fraction overflowed") + return TalerAmount( + value = valueAdd, frac = fracAdd, currency = currency + ).normalize() + } + + override fun equals(other: Any?): Boolean { + return other is TalerAmount && + other.value == this.value && + other.frac == this.frac && + other.currency == this.currency + } + + override fun toString(): String { + val fracNoTrailingZero = this.frac.toString().dropLastWhile { it == '0' } + if (fracNoTrailingZero.isEmpty()) return "$currency:$value" + return "$currency:$value.$fracNoTrailingZero" + } + + internal object Serializer : KSerializer<TalerAmount> { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("TalerAmount", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: TalerAmount) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): TalerAmount { + return TalerAmount(decoder.decodeString()) + } + } + + companion object { + const val FRACTION_BASE = 100000000 + } +} + + +/** + * Internal representation of relative times. The + * "forever" case is represented with Long.MAX_VALUE. + */ +@Serializable() +data class RelativeTime( + @Serializable(with = RelativeTime.Serializer::class) + val d_us: Duration +) { + internal object Serializer : KSerializer<Duration> { + override fun serialize(encoder: Encoder, value: Duration) { + if (value == ChronoUnit.FOREVER.duration) { + encoder.encodeString("forever") + } else { + encoder.encodeLong(TimeUnit.MICROSECONDS.convert(value)) + } + } + + override fun deserialize(decoder: Decoder): Duration { + val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON") + val maybeDUs = jsonInput.decodeJsonElement().jsonPrimitive + if (maybeDUs.isString) { + if (maybeDUs.content != "forever") throw badRequest("Only 'forever' allowed for d_us as string, but '${maybeDUs.content}' was found") + return ChronoUnit.FOREVER.duration + } + val dUs: Long = maybeDUs.longOrNull + ?: throw badRequest("Could not convert d_us: '${maybeDUs.content}' to a number") + when { + dUs < 0 -> throw badRequest("Negative duration specified.") + dUs > MAX_SAFE_INTEGER -> throw badRequest("d_us value $dUs exceed cap (2^53-1)") + else -> return Duration.of(dUs, ChronoUnit.MICROS) + } + } + + override val descriptor: SerialDescriptor = JsonElement.serializer().descriptor + } +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt @@ -0,0 +1,702 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2019 Stanisci and Dold. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin 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 Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +package tech.libeufin.bank + +import io.ktor.http.* +import io.ktor.server.application.* +import kotlinx.serialization.Serializable +import net.taler.wallet.crypto.Base32Crockford +import net.taler.wallet.crypto.EncodingException +import java.time.Duration +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.* +import kotlinx.serialization.encoding.* + +/** + * Allowed lengths for fractional digits in amounts. + */ +enum class FracDigits { + TWO, EIGHT +} + + +// Allowed values for bank transactions directions. +enum class TransactionDirection { + credit, + debit +} + +/** + * HTTP response type of successful token refresh. + * access_token is the Crockford encoding of the 32 byte + * access token, whereas 'expiration' is the point in time + * when this token expires. + */ +@Serializable +data class TokenSuccessResponse( + val access_token: String, + val expiration: TalerProtocolTimestamp +) + +/** + * Error object to respond to the client. The + * 'code' field takes values from the GANA gnu-taler-error-code + * specification. 'hint' is a human-readable description + * of the error. + */ +@Serializable +data class TalerError( + val code: Int, + val hint: String? = null, + val detail: String? = null +) + +/* Contains contact data to send TAN challges to the +* users, to let them complete cashout operations. */ +@Serializable +data class ChallengeContactData( + val email: String? = null, + val phone: String? = null +) + +// Type expected at POST /accounts +@Serializable +data class RegisterAccountRequest( + val username: String, + val password: String, + val name: String, + val is_public: Boolean = false, + val is_taler_exchange: Boolean = false, + val challenge_contact_data: ChallengeContactData? = null, + // External bank account where to send cashout amounts. + val cashout_payto_uri: String? = null, + // Bank account internal to Libeufin-Bank. + val internal_payto_uri: String? = null +) + + +/** + * Type expected at POST /accounts/{USERNAME}/token + * It complies with Taler's design document #49 + */ +@Serializable +data class TokenRequest( + val scope: TokenScope, + val duration: RelativeTime? = null, + val refreshable: Boolean = false +) + +/** + * Convenience type to throw errors along the bank activity + * and that is meant to be caught by Ktor and responded to the + * client. + */ +class LibeufinBankException( + // Status code that Ktor will set for the response. + val httpStatus: HttpStatusCode, + // Error detail object, after Taler API. + val talerError: TalerError +) : Exception(talerError.hint) + +/** + * Convenience type to hold customer data, typically after such + * data gets fetched from the database. It is also used to _insert_ + * customer data to the database. + */ +data class Customer( + val login: String, + val passwordHash: String, + val name: String, + /** + * Only non-null when this object is defined _by_ the + * database. + */ + val dbRowId: Long? = null, + val email: String? = null, + val phone: String? = null, + /** + * External bank account where customers send + * their cashout amounts. + */ + val cashoutPayto: String? = null, + /** + * Currency of the external bank account where + * customers send their cashout amounts. + */ + val cashoutCurrency: String? = null +) + +/** + * Convenience type to get and set bank account information + * from/to the database. + */ +data class BankAccount( + val internalPaytoUri: String, + // Database row ID of the customer that owns this bank account. + val owningCustomerId: Long, + val bankAccountId: Long? = null, // null at INSERT. + val isPublic: Boolean = false, + val isTalerExchange: Boolean = false, + /** + * Because bank accounts MAY be funded by an external currency, + * local bank accounts need to query Nexus, in order to find this + * out. This field is a pointer to the latest incoming payment that + * was contained in a Nexus history response. + * + * Typically, the 'admin' bank account uses this field, in order + * to initiate Taler withdrawals that depend on an external currency + * being wired by wallet owners. + */ + val lastNexusFetchRowId: Long = 0L, + val balance: TalerAmount? = null, // null when a new bank account gets created. + val hasDebt: Boolean, + val maxDebt: TalerAmount +) + +// Allowed values for cashout TAN channels. +enum class TanChannel { + sms, + email, + file // Writes cashout TANs to /tmp, for testing. +} + +// Scopes for authentication tokens. +enum class TokenScope { + readonly, + readwrite, + refreshable // Not spec'd as a scope! +} + +/** + * Convenience type to set/get authentication tokens to/from + * the database. + */ +data class BearerToken( + val content: ByteArray, + val scope: TokenScope, + val isRefreshable: Boolean = false, + val creationTime: Instant, + val expirationTime: Instant, + /** + * Serial ID of the database row that hosts the bank customer + * that is associated with this token. NOTE: if the token is + * refreshed by a client that doesn't have a user+password login + * in the system, the creator remains always the original bank + * customer that created the very first token. + */ + val bankCustomer: Long +) + +/** + * Convenience type to _communicate_ a bank transfer to the + * database procedure, NOT representing therefore any particular + * table. The procedure will then retrieve all the tables data + * from this type. + */ +data class BankInternalTransaction( + // Database row ID of the internal bank account sending the payment. + val creditorAccountId: Long, + // Database row ID of the internal bank account receiving the payment. + val debtorAccountId: Long, + val subject: String, + val amount: TalerAmount, + val transactionDate: Instant, + val accountServicerReference: String = "not used", // ISO20022 + val endToEndId: String = "not used", // ISO20022 + val paymentInformationId: String = "not used" // ISO20022 +) + +/** + * Convenience type representing bank transactions as they + * are in the respective database table. Only used to _get_ + * the information from the database. + */ +data class BankAccountTransaction( + val creditorPaytoUri: String, + val creditorName: String, + val debtorPaytoUri: String, + val debtorName: String, + val subject: String, + val amount: TalerAmount, + val transactionDate: Instant, + /** + * Is the transaction debit, or credit for the + * bank account pointed by this object? + */ + val direction: TransactionDirection, + /** + * database row ID of the bank account that is + * impacted by the direction. For example, if the + * direction is debit, then this value points to the + * bank account of the payer. + */ + val bankAccountId: Long, + // Null if this type is used to _create_ one transaction. + val dbRowId: Long? = null, + // Following are ISO20022 specific. + val accountServicerReference: String, + val paymentInformationId: String, + val endToEndId: String, +) + +/** + * Represents a Taler withdrawal operation, as it is + * stored in the respective database table. + */ +data class TalerWithdrawalOperation( + val withdrawalUuid: UUID, + val amount: TalerAmount, + val selectionDone: Boolean = false, + val aborted: Boolean = false, + val confirmationDone: Boolean = false, + val reservePub: String?, + val selectedExchangePayto: String?, + val walletBankAccount: Long +) + +/** + * Represents a cashout operation, as it is stored + * in the respective database table. + */ +data class Cashout( + val cashoutUuid: UUID, + val localTransaction: Long? = null, + val amountDebit: TalerAmount, + val amountCredit: TalerAmount, + val buyAtRatio: Int, + val buyInFee: TalerAmount, + val sellAtRatio: Int, + val sellOutFee: TalerAmount, + val subject: String, + val creationTime: Instant, + val tanConfirmationTime: Instant? = null, + val tanChannel: TanChannel, + val tanCode: String, + val bankAccount: Long, + val credit_payto_uri: String, + val cashoutCurrency: String +) + +// Type to return as GET /config response +@Serializable // Never used to parse JSON. +data class Config( + val currency: CurrencySpecification, +) { + val name: String = "libeufin-bank" + val version: String = "0:0:0" + val have_cashout: Boolean = false + // Following might probably get renamed: + val fiat_currency: String? = null +} + +enum class CorebankCreditDebitInfo { + credit, debit +} + +@Serializable +data class Balance( + val amount: TalerAmount, + val credit_debit_indicator: CorebankCreditDebitInfo, +) + +/** + * GET /accounts response. + */ +@Serializable +data class AccountMinimalData( + val username: String, + val name: String, + val balance: Balance, + val debit_threshold: TalerAmount +) + +/** + * Response type of GET /accounts. + */ +@Serializable +data class ListBankAccountsResponse( + val accounts: MutableList<AccountMinimalData> = mutableListOf() +) + +/** + * GET /accounts/$USERNAME response. + */ +@Serializable +data class AccountData( + val name: String, + val balance: Balance, + val payto_uri: String, + val debit_threshold: TalerAmount, + val contact_data: ChallengeContactData? = null, + val cashout_payto_uri: String? = null, +) + +/** + * Response type of corebank API transaction initiation. + */ +@Serializable +data class BankAccountTransactionCreate( + val payto_uri: String, + val amount: TalerAmount +) + +/* History element, either from GET /transactions/T_ID + or from GET /transactions */ +@Serializable +data class BankAccountTransactionInfo( + val creditor_payto_uri: String, + val debtor_payto_uri: String, + val amount: TalerAmount, + val direction: TransactionDirection, + val subject: String, + val row_id: Long, // is T_ID + val date: TalerProtocolTimestamp +) + +// Response type for histories, namely GET /transactions +@Serializable +data class BankAccountTransactionsResponse( + val transactions: List<BankAccountTransactionInfo> +) + +// Taler withdrawal request. +@Serializable +data class BankAccountCreateWithdrawalRequest( + val amount: TalerAmount +) + +// Taler withdrawal response. +@Serializable +data class BankAccountCreateWithdrawalResponse( + val withdrawal_id: String, + val taler_withdraw_uri: String +) + +// Taler withdrawal details response +@Serializable +data class BankAccountGetWithdrawalResponse( + val amount: TalerAmount, + val aborted: Boolean, + val confirmation_done: Boolean, + val selection_done: Boolean, + val selected_reserve_pub: String? = null, + val selected_exchange_account: String? = null +) + +typealias ResourceName = String + +/** + * Checks if the input Customer has the rights over ResourceName. + */ +fun ResourceName.canI(c: Customer, withAdmin: Boolean = true): Boolean { + if (c.login == this) return true + if (c.login == "admin" && withAdmin) return true + return false +} + +/** + * Factors out the retrieval of the resource name from + * the URI. The resource looked for defaults to "USERNAME" + * as this is frequently mentioned resource along the endpoints. + * + * This helper is recommended because it returns a ResourceName + * type that then offers the ".canI()" helper to check if the user + * has the rights on the resource. + */ +fun ApplicationCall.getResourceName(param: String): ResourceName = + this.expectUriComponent(param) + +/** + * This type communicates the result of deleting an account + * from the database. + */ +enum class CustomerDeletionResult { + SUCCESS, + CUSTOMER_NOT_FOUND, + BALANCE_NOT_ZERO +} + +/** + * This type communicates the result of a database operation + * to confirm one withdrawal operation. + */ +enum class WithdrawalConfirmationResult { + SUCCESS, + OP_NOT_FOUND, + EXCHANGE_NOT_FOUND, + BALANCE_INSUFFICIENT, + + /** + * This state indicates that the withdrawal was already + * confirmed BUT Kotlin did not detect it and still invoked + * the SQL procedure to confirm the withdrawal. This is + * conflictual because only Kotlin is responsible to check + * for idempotency, and this state witnesses a failure in + * this regard. + */ + CONFLICT +} + +/** + * Communicates the result of creating a bank transaction in the database. + */ +enum class BankTransactionResult { + NO_CREDITOR, + NO_DEBTOR, + SUCCESS, + CONFLICT // balance insufficient +} + +// GET /config response from the Taler Integration API. +@Serializable +data class TalerIntegrationConfigResponse( + val currency: String, + val currency_specification: CurrencySpecification, +) { + val name: String = "taler-bank-integration"; + val version: String = "0:0:0"; +} + +@Serializable +data class CurrencySpecification( + val name: String, + val decimal_separator: String, + val num_fractional_input_digits: Int, + val num_fractional_normal_digits: Int, + val num_fractional_trailing_zero_digits: Int, + val is_currency_name_leading: Boolean, + val alt_unit_names: Map<String, String> +) + +/** + * Withdrawal status as specified in the Taler Integration API. + */ +@Serializable +data class BankWithdrawalOperationStatus( + // Indicates whether the withdrawal was aborted. + val aborted: Boolean, + + /* Has the wallet selected parameters for the withdrawal operation + (exchange and reserve public key) and successfully sent it + to the bank? */ + val selection_done: Boolean, + + /* The transfer has been confirmed and registered by the bank. + Does not guarantee that the funds have arrived at the exchange + already. */ + val transfer_done: Boolean, + + /* Amount that will be withdrawn with this operation + (raw amount without fee considerations). */ + val amount: TalerAmount, + + /* Bank account of the customer that is withdrawing, as a + ``payto`` URI. */ + val sender_wire: String? = null, + + // Suggestion for an exchange given by the bank. + val suggested_exchange: String? = null, + + /* URL that the user needs to navigate to in order to + complete some final confirmation (e.g. 2FA). + It may contain withdrawal operation id */ + val confirm_transfer_url: String? = null, + + // Wire transfer types supported by the bank. + val wire_types: MutableList<String> = mutableListOf("iban") +) + +/** + * Selection request on a Taler withdrawal. + */ +@Serializable +data class BankWithdrawalOperationPostRequest( + val reserve_pub: String, + val selected_exchange: String, +) + +/** + * Response to the wallet after it selects the exchange + * and the reserve pub. + */ +@Serializable +data class BankWithdrawalOperationPostResponse( + val transfer_done: Boolean, + val confirm_transfer_url: String? = null +) + +/** + * Request to an /admin/add-incoming request from + * the Taler Wire Gateway API. + */ +@Serializable +data class AddIncomingRequest( + val amount: TalerAmount, + val reserve_pub: EddsaPublicKey, + val debit_account: String +) + +/** + * Response to /admin/add-incoming + */ +@Serializable +data class AddIncomingResponse( + val timestamp: TalerProtocolTimestamp, + val row_id: Long +) + +@Serializable +data class TWGConfigResponse( + val name: String = "taler-wire-gateway", + val version: String = "0:0:0", + val currency: String +) + +/** + * Response of a TWG /history/incoming call. + */ +@Serializable +data class IncomingHistory( + val incoming_transactions: List<IncomingReserveTransaction>, + val credit_account: String // Receiver's Payto URI. +) + +/** + * TWG's incoming payment record. + */ +@Serializable +data class IncomingReserveTransaction( + val type: String = "RESERVE", + val row_id: Long, // DB row ID of the payment. + val date: TalerProtocolTimestamp, + val amount: TalerAmount, + val debit_account: String, // Payto of the sender. + val reserve_pub: EddsaPublicKey +) + +/** + * Response of a TWG /history/outgoing call. + */ +@Serializable +data class OutgoingHistory( + val outgoing_transactions: List<OutgoingTransaction>, + val debit_account: String // Debitor's Payto URI. +) + +/** + * TWG's outgoinf payment record. + */ +@Serializable +data class OutgoingTransaction( + val row_id: Long, // DB row ID of the payment. + val date: TalerProtocolTimestamp, + val amount: TalerAmount, + val credit_account: String, // Payto of the receiver. + val wtid: ShortHashCode, + val exchange_base_url: String, +) + +/** + * TWG's request to pay a merchant. + */ +@Serializable +data class TransferRequest( + val request_uid: HashCode, + val amount: TalerAmount, + val exchange_base_url: String, + val wtid: ShortHashCode, + val credit_account: String +) + +/** + * TWG's response to merchant payouts + */ +@Serializable +data class TransferResponse( + val timestamp: TalerProtocolTimestamp, + val row_id: Long +) + +/** + * Response to GET /public-accounts + */ +@Serializable +data class PublicAccountsResponse( + val public_accounts: MutableList<PublicAccount> = mutableListOf() +) + +/** + * Single element of GET /public-accounts list. + */ +@Serializable +data class PublicAccount( + val payto_uri: String, + val balance: Balance, + val account_name: String +) + +/** + * Request of PATCH /accounts/{USERNAME}/auth + */ +@Serializable +data class AccountPasswordChange( + val new_password: String +) + +/** + * Request of PATCH /accounts/{USERNAME} + */ +@Serializable +data class AccountReconfiguration( + val challenge_contact_data: ChallengeContactData?, + val cashout_address: String?, + val name: String?, + val is_exchange: Boolean? +) + +/** + * This type expresses the outcome of updating the account + * data in the database. + */ +enum class AccountReconfigDBResult { + /** + * This indicates that despite the customer row was + * found in the database, its related bank account was not. + * This condition is a hard failure of the bank, since + * every customer must have one (and only one) bank account. + */ + BANK_ACCOUNT_NOT_FOUND, + + /** + * The customer row wasn't found in the database. This error + * should be rare, as the client got authenticated in the first + * place, before the handler could try the reconfiguration in + * the database. + */ + CUSTOMER_NOT_FOUND, + + /** + * Reconfiguration successful. + */ + SUCCESS +} +\ No newline at end of file diff --git a/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt b/bank/src/main/kotlin/tech/libeufin/bank/helpers.kt @@ -33,9 +33,6 @@ import java.net.URL import java.time.Instant import java.util.* - -const val AMOUNT_FRACTION_BASE = 100000000 - private val logger: Logger = LoggerFactory.getLogger("tech.libeufin.bank.helpers") fun ApplicationCall.expectUriComponent(componentName: String) = @@ -191,86 +188,6 @@ fun badRequest( // Generates a new Payto-URI with IBAN scheme. fun genIbanPaytoUri(): String = "payto://iban/SANDBOXX/${getIban()}" -// Parses Taler amount, returning null if the input is invalid. -fun parseTalerAmount2( - amount: String, fracDigits: FracDigits -): TalerAmount? { - val format = when (fracDigits) { - FracDigits.TWO -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?)?$" - FracDigits.EIGHT -> "^([A-Z]+):([0-9]+)(\\.[0-9][0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?[0-9]?)?$" - } - val match = Regex(format).find(amount) ?: return null - val _value = match.destructured.component2() - // Fraction is at most 8 digits, so it's always < than MAX_INT. - val fraction: Int = match.destructured.component3().run { - var frac = 0 - var power = AMOUNT_FRACTION_BASE - if (this.isNotEmpty()) - // Skips the dot and processes the fractional chars. - this.substring(1).forEach { chr -> - power /= 10 - frac += power * chr.digitToInt() - } - return@run frac - } - val value: Long = try { - _value.toLong() - } catch (e: NumberFormatException) { - return null - } - return TalerAmount( - value = value, frac = fraction, currency = match.destructured.component1() - ) -} - -/** - * This helper takes the serialized version of a Taler Amount - * type and parses it into Libeufin's internal representation. - * It returns a TalerAmount type, or throws a LibeufinBankException - * it the input is invalid. Such exception will be then caught by - * Ktor, transformed into the appropriate HTTP error type, and finally - * responded to the client. - */ -fun parseTalerAmount( - amount: String, fracDigits: FracDigits = FracDigits.EIGHT // FIXME: fracDigits should come from config. -): TalerAmount { - val maybeAmount = parseTalerAmount2(amount, fracDigits) ?: throw LibeufinBankException( - httpStatus = HttpStatusCode.BadRequest, talerError = TalerError( - code = TalerErrorCode.TALER_EC_BANK_BAD_FORMAT_AMOUNT.code, hint = "Invalid amount: $amount" - ) - ) - return maybeAmount -} - -private fun normalizeAmount(amt: TalerAmount): TalerAmount { - if (amt.frac > AMOUNT_FRACTION_BASE) { - val normalValue = amt.value + (amt.frac / AMOUNT_FRACTION_BASE) - val normalFrac = amt.frac % AMOUNT_FRACTION_BASE - return TalerAmount( - value = normalValue, frac = normalFrac, currency = amt.currency - ) - } - return amt -} - - -// Adds two amounts and returns the normalized version. -private fun amountAdd(first: TalerAmount, second: TalerAmount): TalerAmount { - if (first.currency != second.currency) throw badRequest( - "Currency mismatch, balance '${first.currency}', price '${second.currency}'", - TalerErrorCode.TALER_EC_GENERIC_CURRENCY_MISMATCH - ) - val valueAdd = first.value + second.value - if (valueAdd < first.value) throw badRequest("Amount value overflowed") - val fracAdd = first.frac + second.frac - if (fracAdd < first.frac) throw badRequest("Amount fraction overflowed") - return normalizeAmount( - TalerAmount( - value = valueAdd, frac = fracAdd, currency = first.currency - ) - ) -} - /** * Checks whether the balance could cover the due amount. Returns true * when it does, false otherwise. Note: this function is only a checker, @@ -281,9 +198,9 @@ private fun amountAdd(first: TalerAmount, second: TalerAmount): TalerAmount { fun isBalanceEnough( balance: TalerAmount, due: TalerAmount, maxDebt: TalerAmount, hasBalanceDebt: Boolean ): Boolean { - val normalMaxDebt = normalizeAmount(maxDebt) // Very unlikely to be needed. + val normalMaxDebt = maxDebt.normalize() // Very unlikely to be needed. if (hasBalanceDebt) { - val chargedBalance = amountAdd(balance, due) + val chargedBalance = balance + due if (chargedBalance.value > normalMaxDebt.value) return false // max debt surpassed if ((chargedBalance.value == normalMaxDebt.value) && (chargedBalance.frac > maxDebt.frac)) return false return true @@ -300,7 +217,7 @@ fun isBalanceEnough( val valueDiff = if (balance.value < due.value) due.value - balance.value else 0L val fracDiff = if (balance.frac < due.frac) due.frac - balance.frac else 0 // Getting the normalized version of such diff. - val normalDiff = normalizeAmount(TalerAmount(valueDiff, fracDiff, balance.currency)) + val normalDiff = TalerAmount(valueDiff, fracDiff, balance.currency).normalize() // Failing if the normalized diff surpasses the max debt. if (normalDiff.value > normalMaxDebt.value) return false if ((normalDiff.value == normalMaxDebt.value) && (normalDiff.frac > normalMaxDebt.frac)) return false diff --git a/bank/src/test/kotlin/AmountTest.kt b/bank/src/test/kotlin/AmountTest.kt @@ -20,6 +20,7 @@ import org.junit.Test import tech.libeufin.bank.* +import kotlin.test.assertEquals class AmountTest { @Test @@ -71,22 +72,37 @@ class AmountTest { } @Test - fun parseTalerAmountTest() { - val one = "EUR:1" - var obj = parseTalerAmount2(one, FracDigits.TWO) - assert(obj!!.value == 1L && obj.frac == 0 && obj.currency == "EUR") - val onePointZero = "EUR:1.00" - obj = parseTalerAmount2(onePointZero, FracDigits.TWO) - assert(obj!!.value == 1L && obj.frac == 0) - val onePointZeroOne = "EUR:1.01" - obj = parseTalerAmount2(onePointZeroOne, FracDigits.TWO) - assert(obj!!.value == 1L && obj.frac == 1000000) - obj = parseTalerAmount2("EUR:0.00000001", FracDigits.EIGHT) - assert(obj!!.value == 0L && obj.frac == 1) - // Setting two fractional digits. - obj = parseTalerAmount2("EUR:0.01", FracDigits.TWO) // one cent - assert(obj!!.value == 0L && obj.frac == 1000000) - obj = parseTalerAmount2("EUR:0.1", FracDigits.TWO) // ten cents - assert(obj!!.value == 0L && obj.frac == 10000000) + fun parseValid() { + assertEquals(TalerAmount("EUR:4"), TalerAmount(4L, 0, "EUR")) + assertEquals(TalerAmount("EUR:0.02"), TalerAmount(0L, 2000000, "EUR")) + assertEquals(TalerAmount(" EUR:4.12"), TalerAmount(4L, 12000000, "EUR")) + assertEquals(TalerAmount(" *LOCAL:4444.1000"), TalerAmount(4444L, 10000000, "*LOCAL")) + } + + @Test + fun parseInvalid() { + assertException("Empty amount") {TalerAmount("")} + assertException("Missing value") {TalerAmount("EUR")} + assertException("Currency too big") {TalerAmount("AZERTYUIOPQSD:")} + assertException("Value specified in amount is too large") {TalerAmount("EUR:${Long.MAX_VALUE}")} + assertException("Fractional value too precise") {TalerAmount("EUR:4.000000000")} + assertException("Invalid fractional value") {TalerAmount("EUR:4.4a")} + } + + @Test + fun normalize() { + assertEquals(TalerAmount("EUR:6"), TalerAmount(4L, 2 * TalerAmount.FRACTION_BASE, "EUR").normalize()) + assertEquals(TalerAmount("EUR:6.00000001"), TalerAmount(4L, 2 * TalerAmount.FRACTION_BASE + 1, "EUR").normalize()) + assertException("Amount value overflowed") { TalerAmount(Long.MAX_VALUE, 2 * TalerAmount.FRACTION_BASE + 1, "EUR").normalize() } + assertException("Amount value overflowed") { TalerAmount(MAX_SAFE_INTEGER, 2 * TalerAmount.FRACTION_BASE + 1, "EUR").normalize() } + } + + @Test + fun add() { + assertEquals(TalerAmount("EUR:6.41") + TalerAmount("EUR:4.69"), TalerAmount("EUR:11.1")) + assertException("Amount value overflowed") { TalerAmount(MAX_SAFE_INTEGER - 5, 0, "EUR") + TalerAmount(6, 0, "EUR") } + assertException("Amount value overflowed") { TalerAmount(Long.MAX_VALUE, 0, "EUR") + TalerAmount(1, 0, "EUR") } + assertException("Amount value overflowed") { TalerAmount(MAX_SAFE_INTEGER - 5, TalerAmount.FRACTION_BASE - 1, "EUR") + TalerAmount(5, 2, "EUR") } + assertException("Amount fraction overflowed") { TalerAmount(0, Int.MAX_VALUE, "EUR") + TalerAmount(0, 1, "EUR") } } } \ No newline at end of file diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt @@ -43,7 +43,6 @@ fun HttpResponse.assertStatus(status: HttpStatusCode): HttpResponse { fun HttpResponse.assertOk(): HttpResponse = assertStatus(HttpStatusCode.OK) fun HttpResponse.assertBadRequest(): HttpResponse = assertStatus(HttpStatusCode.BadRequest) - fun BankTransactionResult.assertSuccess() { assertEquals(BankTransactionResult.SUCCESS, this) } @@ -57,6 +56,15 @@ suspend fun assertTime(min: Int, max: Int, lambda: suspend () -> Unit) { assert(time <= max) { "Expected to last at most $max ms, lasted $time" } } +fun assertException(msg: String, lambda: () -> Unit) { + try { + lambda() + throw Exception("Expected failure") + } catch (e: Exception) { + assertEquals(msg, e.message) + } +} + /* ----- Body helper ----- */ inline fun <reified B> HttpRequestBuilder.jsonBody(b: B, deflate: Boolean = false) {