commit 427e71c9586c8cbf4e71b7abf268a85c8e03bab2
parent 5f0c56d06e7cc1832eb019f046921b1acecf3752
Author: Antoine A <>
Date: Mon, 20 Nov 2023 15:09:25 +0000
Bounce malformed incoming transactions and improve exchange transaction
logic
Diffstat:
8 files changed, 259 insertions(+), 116 deletions(-)
diff --git a/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt b/bank/src/main/kotlin/tech/libeufin/bank/db/TransactionDAO.kt
@@ -43,6 +43,7 @@ class TransactionDAO(private val db: Database) {
amount: TalerAmount,
timestamp: Instant,
): BankTransactionResult = db.serializable { conn ->
+ val now = timestamp.toDbMicros() ?: throw faultyTimestampByBank();
conn.transaction {
val stmt = conn.prepareStatement("""
SELECT
@@ -64,7 +65,7 @@ class TransactionDAO(private val db: Database) {
stmt.setString(3, subject)
stmt.setLong(4, amount.value)
stmt.setInt(5, amount.frac)
- stmt.setLong(6, timestamp.toDbMicros() ?: throw faultyTimestampByBank())
+ stmt.setLong(6, now)
stmt.executeQuery().use {
when {
!it.next() -> throw internalServerError("Bank transaction didn't properly return")
@@ -78,31 +79,65 @@ class TransactionDAO(private val db: Database) {
val debitAccountId = it.getLong("out_debit_bank_account_id")
val debitRowId = it.getLong("out_debit_row_id")
val metadata = TxMetadata.parse(subject)
- if (it.getBoolean("out_creditor_is_exchange")) {
- if (metadata is IncomingTxMetadata) {
- conn.prepareStatement("CALL register_incoming(?, ?)").run {
+ val exchangeCreditor = it.getBoolean("out_creditor_is_exchange")
+ val exchangeDebtor = it.getBoolean("out_debtor_is_exchange")
+ if (exchangeCreditor && exchangeDebtor) {
+ val kind = when (metadata) {
+ is IncomingTxMetadata -> "an incoming taler "
+ is OutgoingTxMetadata -> "an outgoing taler"
+ null -> "a common"
+ };
+ logger.warn("exchange account $exchangeDebtor sent $kind transaction to exchange account $exchangeCreditor, this should never happens and is not bounced to prevent bouncing loop")
+ } else if (exchangeCreditor) {
+ val bounce = if (metadata is IncomingTxMetadata) {
+ val registered = conn.prepareStatement("CALL register_incoming(?, ?)").run {
setBytes(1, metadata.reservePub.raw)
setLong(2, creditRowId)
- executeUpdate() // TODO check reserve pub reuse
+ executeProcedureViolation()
}
+ if (!registered) {
+ logger.warn("exchange account $creditAccountId received an incoming taler transaction $creditRowId with an already used reserve public key")
+
+ }
+ !registered
} else {
- // TODO bounce
- logger.warn("exchange account $creditAccountId received a transaction $creditRowId with malformed metadata, will bounce in future version")
+ logger.warn("exchange account $creditAccountId received a transaction $creditRowId with malformed metadata")
+ true
}
- }
- if (it.getBoolean("out_debtor_is_exchange")) {
+ if (bounce) {
+ // No error can happens because an opposite transaction already took place in the same transaction
+ conn.prepareStatement("""
+ SELECT bank_wire_transfer(
+ ?, ?, ?, (?, ?)::taler_amount, ?,
+ NULL, NULL, NULL
+ );
+ """
+ ).run {
+ setLong(1, debitAccountId)
+ setLong(2, creditAccountId)
+ setString(3, "Bounce $creditRowId") // TODO better subject
+ setLong(4, amount.value)
+ setInt(5, amount.frac)
+ setLong(6, now)
+ executeQuery()
+ }
+ }
+ } else if (exchangeDebtor) {
if (metadata is OutgoingTxMetadata) {
- conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?, ?, ?)").run {
+ val registered = conn.prepareStatement("CALL register_outgoing(NULL, ?, ?, ?, ?, ?, ?)").run {
setBytes(1, metadata.wtid.raw)
setString(2, metadata.exchangeBaseUrl.url)
setLong(3, debitAccountId)
setLong(4, creditAccountId)
setLong(5, debitRowId)
setLong(6, creditRowId)
- executeUpdate() // TODO check wtid reuse
+ executeProcedureViolation()
+ }
+ if (!registered) {
+ logger.warn("exchange account $debitAccountId sent an outgoing taler transaction $debitRowId with an already used withdraw ID, use the API to catch this error")
}
} else {
- logger.warn("exchange account $debitAccountId sent a transaction $debitRowId with malformed metadata")
+ logger.warn("exchange account $debitAccountId sent a transaction $debitRowId with malformed metadata, use the API instead")
}
}
BankTransactionResult.Success(debitRowId)
diff --git a/bank/src/test/kotlin/CoreBankApiTest.kt b/bank/src/test/kotlin/CoreBankApiTest.kt
@@ -240,9 +240,9 @@ class CoreBankAccountsApiTest {
"username" to "foo$it"
}
}.assertCreated()
- assertBalance("foo$it", CreditDebitInfo.credit, "KUDOS:100")
+ assertBalance("foo$it", "+KUDOS:100")
}
- assertBalance("admin", CreditDebitInfo.debit, "KUDOS:10000")
+ assertBalance("admin", "-KUDOS:10000")
// Check unsufficient fund
client.post("/accounts") {
@@ -308,11 +308,11 @@ class CoreBankAccountsApiTest {
// fail to delete, due to a non-zero balance.
- tx("exchange", "KUDOS:1", "merchant")
+ tx("customer", "KUDOS:1", "merchant")
client.delete("/accounts/merchant") {
pwAuth("admin")
}.assertConflict(TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO)
- tx("merchant", "KUDOS:1", "exchange")
+ tx("merchant", "KUDOS:1", "customer")
client.delete("/accounts/merchant") {
pwAuth("admin")
}.assertNoContent()
@@ -563,11 +563,11 @@ class CoreBankTransactionsApiTest {
// Gen three transactions from merchant to exchange
repeat(3) {
- tx("merchant", "KUDOS:0.$it", "exchange")
+ tx("merchant", "KUDOS:0.$it", "customer")
}
// Gen two transactions from exchange to merchant
repeat(2) {
- tx("exchange", "KUDOS:0.$it", "merchant")
+ tx("customer", "KUDOS:0.$it", "merchant")
}
// Check no useless polling
@@ -597,12 +597,12 @@ class CoreBankTransactionsApiTest {
}
}
delay(100)
- tx("merchant", "KUDOS:4.2", "exchange")
+ tx("merchant", "KUDOS:4.2", "customer")
}
// Testing ranges.
repeat(30) {
- tx("merchant", "KUDOS:0.001", "exchange")
+ tx("merchant", "KUDOS:0.001", "customer")
}
// forward range:
@@ -713,19 +713,9 @@ class CoreBankTransactionsApiTest {
}
}.assertConflict(TalerErrorCode.BANK_SAME_ACCOUNT)
- suspend fun checkBalance(
- merchantDebt: Boolean,
- merchantAmount: String,
- customerDebt: Boolean,
- customerAmount: String,
- ) {
- assertBalance("merchant", if (merchantDebt) CreditDebitInfo.debit else CreditDebitInfo.credit, merchantAmount)
- assertBalance("customer", if (customerDebt) CreditDebitInfo.debit else CreditDebitInfo.credit, customerAmount)
- }
-
// Init state
- assertBalance("merchant", CreditDebitInfo.debit, "KUDOS:2.4")
- assertBalance("customer", CreditDebitInfo.credit, "KUDOS:0")
+ assertBalance("merchant", "+KUDOS:0")
+ assertBalance("customer", "+KUDOS:0")
// Send 2 times 3
repeat(2) {
tx("merchant", "KUDOS:3", "customer")
@@ -733,20 +723,39 @@ class CoreBankTransactionsApiTest {
client.post("/accounts/merchant/transactions") {
pwAuth("merchant")
json {
- "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:3"
+ "payto_uri" to "$customerPayto?message=payout2&amount=KUDOS:5"
}
}.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
- assertBalance("merchant", CreditDebitInfo.debit, "KUDOS:8.4")
- assertBalance("customer", CreditDebitInfo.credit, "KUDOS:6")
+ assertBalance("merchant", "-KUDOS:6")
+ assertBalance("customer", "+KUDOS:6")
// Send throught debt
- client.post("/accounts/customer/transactions") {
- pwAuth("customer")
- json {
- "payto_uri" to "$merchantPayto?message=payout2&amount=KUDOS:10"
- }
- }.assertOk()
- assertBalance("merchant", CreditDebitInfo.credit, "KUDOS:1.6")
- assertBalance("customer", CreditDebitInfo.debit, "KUDOS:4")
+ tx("customer", "KUDOS:10", "merchant")
+ assertBalance("merchant", "+KUDOS:4")
+ assertBalance("customer", "-KUDOS:4")
+ tx("merchant", "KUDOS:4", "customer")
+
+ // Check bounce
+ assertBalance("merchant", "+KUDOS:0")
+ assertBalance("exchange", "+KUDOS:0")
+ tx("merchant", "KUDOS:1", "exchange", "") // Bounce common to transaction
+ tx("merchant", "KUDOS:1", "exchange", "Malformed") // Bounce malformed transaction
+ val reserve_pub = randShortHashCode();
+ tx("merchant", "KUDOS:1", "exchange", IncomingTxMetadata(reserve_pub).encode()) // Accept incoming
+ tx("merchant", "KUDOS:1", "exchange", IncomingTxMetadata(reserve_pub).encode()) // Bounce reserve_pub reuse
+ assertBalance("merchant", "-KUDOS:1")
+ assertBalance("exchange", "+KUDOS:1")
+
+ // Check warn
+ assertBalance("merchant", "-KUDOS:1")
+ assertBalance("exchange", "+KUDOS:1")
+ tx("exchange", "KUDOS:1", "merchant", "") // Warn common to transaction
+ tx("exchange", "KUDOS:1", "merchant", "Malformed") // Warn malformed transaction
+ val wtid = randShortHashCode()
+ val exchange = ExchangeUrl("http://exchange.example.com/")
+ tx("exchange", "KUDOS:1", "merchant", OutgoingTxMetadata(wtid, exchange).encode()) // Accept outgoing
+ tx("exchange", "KUDOS:1", "merchant", OutgoingTxMetadata(wtid, exchange).encode()) // Warn wtid reuse
+ assertBalance("merchant", "+KUDOS:3")
+ assertBalance("exchange", "-KUDOS:3")
}
}
@@ -894,7 +903,7 @@ class CoreBankWithdrawalApiTest {
withdrawalSelect(uuid)
// Send too much money
- tx("merchant", "KUDOS:5", "exchange")
+ tx("merchant", "KUDOS:5", "customer")
client.post("/withdrawals/$uuid/confirm")
.assertConflict(TalerErrorCode.BANK_UNALLOWED_DEBIT)
@@ -1273,8 +1282,6 @@ class CoreBankCashoutApiTest {
fun history() = bankSetup { _ ->
// TODO auth routine
- fillCashoutInfo("customer")
-
suspend fun HttpResponse.assertHistory(size: Int) {
assertHistoryIds<Cashouts>(size) {
it.cashouts.map { it.cashout_id }
@@ -1308,8 +1315,6 @@ class CoreBankCashoutApiTest {
fun globalHistory() = bankSetup { _ ->
// TODO admin auth routine
- fillCashoutInfo("customer")
-
suspend fun HttpResponse.assertHistory(size: Int) {
assertHistoryIds<GlobalCashouts>(size) {
it.cashouts.map { it.cashout_id }
diff --git a/bank/src/test/kotlin/DatabaseTest.kt b/bank/src/test/kotlin/DatabaseTest.kt
@@ -51,8 +51,8 @@ class DatabaseTest {
@Test
fun serialisation() = bankSetup {
- assertBalance("customer", CreditDebitInfo.credit, "KUDOS:0")
- assertBalance("merchant", CreditDebitInfo.credit, "KUDOS:0")
+ assertBalance("customer", "+KUDOS:0")
+ assertBalance("merchant", "+KUDOS:0")
coroutineScope {
repeat(10) {
launch {
@@ -60,8 +60,8 @@ class DatabaseTest {
}
}
}
- assertBalance("customer", CreditDebitInfo.debit, "KUDOS:4.5")
- assertBalance("merchant", CreditDebitInfo.credit, "KUDOS:4.5")
+ assertBalance("customer", "-KUDOS:4.5")
+ assertBalance("merchant", "+KUDOS:4.5")
coroutineScope {
repeat(5) {
launch {
diff --git a/bank/src/test/kotlin/RevenueApiTest.kt b/bank/src/test/kotlin/RevenueApiTest.kt
@@ -40,6 +40,25 @@ class RevenueApiTest {
}
}
+ suspend fun latestId(): Long {
+ return client.getA("/accounts/merchant/taler-revenue/history?delta=-1")
+ .assertOkJson<MerchantIncomingHistory>().incoming_transactions[0].row_id
+ }
+
+ suspend fun testTrigger(trigger: suspend () -> Unit) {
+ coroutineScope {
+ val id = latestId()
+ launch {
+ assertTime(100, 200) {
+ client.getA("/accounts/merchant/taler-revenue/history?delta=7&start=$id&long_poll_ms=1000")
+ .assertHistory(1)
+ }
+ }
+ delay(100)
+ trigger()
+ }
+ }
+
// TODO auth routine
// Check error when no transactions
@@ -52,8 +71,8 @@ class RevenueApiTest {
}
// Should not show up in the revenue API history
tx("exchange", "KUDOS:10", "merchant", "bogus")
- // Merchant pays exchange once, but that should not appear in the result
- tx("merchant", "KUDOS:10", "exchange", "ignored")
+ // Merchant pays customer once, but that should not appear in the result
+ addIncoming("KUDOS:10")
// Gen two transactions using raw bank transaction logic
repeat(2) {
tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode())
@@ -69,7 +88,7 @@ class RevenueApiTest {
// Check no useless polling
assertTime(0, 100) {
- client.getA("/accounts/merchant/taler-revenue/history?delta=-6&start=14&long_poll_ms=1000")
+ client.getA("/accounts/merchant/taler-revenue/history?delta=-6&long_poll_ms=1000")
.assertHistory(5)
}
@@ -80,15 +99,16 @@ class RevenueApiTest {
}
coroutineScope {
+ val id = latestId()
launch { // Check polling succeed forward
assertTime(100, 200) {
- client.getA("/accounts/merchant/taler-revenue/history?delta=2&start=13&long_poll_ms=1000")
+ client.getA("/accounts/merchant/taler-revenue/history?delta=2&start=$id&long_poll_ms=1000")
.assertHistory(1)
}
}
launch { // Check polling timeout forward
assertTime(200, 300) {
- client.getA("/accounts/merchant/taler-revenue/history?delta=1&start=16&long_poll_ms=200")
+ client.getA("/accounts/merchant/taler-revenue/history?delta=1&start=${id+3}&long_poll_ms=200")
.assertNoContent()
}
}
@@ -96,17 +116,30 @@ class RevenueApiTest {
transfer("KUDOS:10")
}
+ // Test trigger by raw transaction
+ testTrigger {
+ tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode())
+ }
+ // Test trigger by outgoing
+ testTrigger { transfer("KUDOS:9") }
+
// Testing ranges.
- repeat(20) {
+ repeat(5) {
transfer("KUDOS:10")
}
+ val id = latestId()
+
// forward range:
- client.getA("/accounts/merchant/taler-revenue/history?delta=10&start=20")
+ client.getA("/accounts/merchant/taler-revenue/history?delta=10")
+ .assertHistory(10)
+ client.getA("/accounts/merchant/taler-revenue/history?delta=10&start=4")
.assertHistory(10)
// backward range:
- client.getA("/accounts/merchant/taler-revenue/history?delta=-10&start=25")
+ client.getA("/accounts/merchant/taler-revenue/history?delta=-10")
+ .assertHistory(10)
+ client.getA("/accounts/merchant/taler-revenue/history?delta=-10&start=${id-4}")
.assertHistory(10)
}
}
\ No newline at end of file
diff --git a/bank/src/test/kotlin/StatsTest.kt b/bank/src/test/kotlin/StatsTest.kt
@@ -36,7 +36,6 @@ class StatsTest {
setMaxDebt("merchant", TalerAmount("KUDOS:1000"))
setMaxDebt("exchange", TalerAmount("KUDOS:1000"))
setMaxDebt("customer", TalerAmount("KUDOS:1000"))
- fillCashoutInfo("customer")
suspend fun cashin(amount: String) {
db.conn { conn ->
diff --git a/bank/src/test/kotlin/WireGatewayApiTest.kt b/bank/src/test/kotlin/WireGatewayApiTest.kt
@@ -176,6 +176,25 @@ class WireGatewayApiTest {
}
}
+ suspend fun latestId(): Long {
+ return client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-1")
+ .assertOkJson<IncomingHistory>().incoming_transactions[0].row_id
+ }
+
+ suspend fun testTrigger(trigger: suspend () -> Unit) {
+ coroutineScope {
+ val id = latestId()
+ launch {
+ assertTime(100, 200) {
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7&start=$id&long_poll_ms=1000")
+ .assertHistory(1)
+ }
+ }
+ delay(100)
+ trigger()
+ }
+ }
+
authRoutine("/accounts/merchant/taler-wire-gateway/history/incoming?delta=7", method = HttpMethod.Get)
// Check error when no transactions
@@ -193,15 +212,7 @@ class WireGatewayApiTest {
// Gen one transaction using raw bank transaction logic
tx("merchant", "KUDOS:10", "exchange", IncomingTxMetadata(randShortHashCode()).encode())
// Gen one transaction using withdraw logic
- client.postA("/accounts/merchant/withdrawals") {
- json { "amount" to "KUDOS:9" }
- }.assertOkJson<BankAccountCreateWithdrawalResponse> {
- val uuid = it.taler_withdraw_uri.split("/").last()
- withdrawalSelect(uuid)
- client.post("/withdrawals/${uuid}/confirm") {
- pwAuth("merchant")
- }.assertNoContent()
- }
+ withdrawal("KUDOS:9")
// Check ignore bogus subject
client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7")
@@ -213,7 +224,7 @@ class WireGatewayApiTest {
// Check no useless polling
assertTime(0, 100) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&start=15&long_poll_ms=1000")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-6&long_poll_ms=1000")
.assertHistory(5)
}
@@ -224,15 +235,16 @@ class WireGatewayApiTest {
}
coroutineScope {
+ val id = latestId()
launch { // Check polling succeed
assertTime(100, 200) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=2&start=14&long_poll_ms=1000")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=2&start=$id&long_poll_ms=1000")
.assertHistory(1)
}
}
launch { // Check polling timeout
assertTime(200, 300) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=1&start=16&long_poll_ms=200")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=1&start=${id+2}&long_poll_ms=200")
.assertNoContent()
}
}
@@ -241,47 +253,30 @@ class WireGatewayApiTest {
}
// Test trigger by raw transaction
- coroutineScope {
- launch {
- assertTime(100, 200) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7&start=16&long_poll_ms=1000")
- .assertHistory(1)
- }
- }
- delay(100)
+ testTrigger {
tx("merchant", "KUDOS:10", "exchange", IncomingTxMetadata(randShortHashCode()).encode())
- }
+ }
+ // Test trigger by withdraw operation
+ testTrigger { withdrawal("KUDOS:9") }
+ // Test trigger by incoming
+ testTrigger { addIncoming("KUDOS:9") }
- // Test trigger by withdraw operationr
- coroutineScope {
- launch {
- assertTime(100, 200) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=7&start=18&long_poll_ms=1000")
- .assertHistory(1)
- }
- }
- delay(100)
- client.postA("/accounts/merchant/withdrawals") {
- json { "amount" to "KUDOS:9" }
- }.assertOkJson<BankAccountCreateWithdrawalResponse> {
- val uuid = it.taler_withdraw_uri.split("/").last()
- withdrawalSelect(uuid)
- client.postA("/withdrawals/${uuid}/confirm")
- .assertNoContent()
- }
- }
-
- // Testing ranges.
- repeat(20) {
+ // Testing ranges.
+ repeat(5) {
addIncoming("KUDOS:10")
}
+ val id = latestId()
// forward range:
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10&start=20")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10")
+ .assertHistory(10)
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=10&start=4")
.assertHistory(10)
// backward range:
- client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10&start=25")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10")
+ .assertHistory(10)
+ client.getA("/accounts/exchange/taler-wire-gateway/history/incoming?delta=-10&start=${id-4}")
.assertHistory(10)
}
@@ -299,6 +294,25 @@ class WireGatewayApiTest {
}
}
+ suspend fun latestId(): Long {
+ return client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-1")
+ .assertOkJson<OutgoingHistory>().outgoing_transactions[0].row_id
+ }
+
+ suspend fun testTrigger(trigger: suspend () -> Unit) {
+ coroutineScope {
+ val id = latestId()
+ launch {
+ assertTime(100, 200) {
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=7&start=$id&long_poll_ms=1000")
+ .assertHistory(1)
+ }
+ }
+ delay(100)
+ trigger()
+ }
+ }
+
authRoutine("/accounts/merchant/taler-wire-gateway/history/outgoing?delta=7", method = HttpMethod.Get)
// Check error when no transactions
@@ -328,7 +342,7 @@ class WireGatewayApiTest {
// Check no useless polling
assertTime(0, 100) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&start=15&long_poll_ms=1000")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-6&long_poll_ms=1000")
.assertHistory(5)
}
@@ -339,15 +353,16 @@ class WireGatewayApiTest {
}
coroutineScope {
+ val id = latestId()
launch { // Check polling succeed forward
assertTime(100, 200) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=2&start=14&long_poll_ms=1000")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=2&start=$id&long_poll_ms=1000")
.assertHistory(1)
}
}
launch { // Check polling timeout forward
assertTime(200, 300) {
- client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=1&start=16&long_poll_ms=200")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=1&start=${id+2}&long_poll_ms=200")
.assertNoContent()
}
}
@@ -355,17 +370,29 @@ class WireGatewayApiTest {
transfer("KUDOS:10")
}
- // Testing ranges.
- repeat(20) {
+ // Test trigger by raw transaction
+ testTrigger {
+ tx("exchange", "KUDOS:10", "merchant", OutgoingTxMetadata(randShortHashCode(), ExchangeUrl("http://exchange.example.com/")).encode())
+ }
+ // Test trigger by outgoing
+ testTrigger { transfer("KUDOS:9") }
+
+ // Testing ranges
+ repeat(5) {
transfer("KUDOS:10")
}
+ val id = latestId()
// forward range:
- client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10&start=20")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10")
+ .assertHistory(10)
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=10&start=4")
.assertHistory(10)
// backward range:
- client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10&start=25")
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10")
+ .assertHistory(10)
+ client.getA("/accounts/exchange/taler-wire-gateway/history/outgoing?delta=-10&start=${id-4}")
.assertHistory(10)
}
diff --git a/bank/src/test/kotlin/helpers.kt b/bank/src/test/kotlin/helpers.kt
@@ -116,6 +116,7 @@ fun dbSetup(lambda: suspend (Database) -> Unit) {
/* ----- Common actions ----- */
+/** Set [account] debit threshold to [maxDebt] amount */
suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: TalerAmount) {
client.patch("/accounts/$account") {
pwAuth("admin")
@@ -123,16 +124,18 @@ suspend fun ApplicationTestBuilder.setMaxDebt(account: String, maxDebt: TalerAmo
}.assertNoContent()
}
-suspend fun ApplicationTestBuilder.assertBalance(account: String, info: CreditDebitInfo, amount: String) {
+/** Check [account] balance is [amount], [amount] is prefixed with + for credit and - for debit */
+suspend fun ApplicationTestBuilder.assertBalance(account: String, amount: String) {
client.get("/accounts/$account") {
pwAuth("admin")
}.assertOkJson<AccountData> {
val balance = it.balance;
- assertEquals(info, balance.credit_debit_indicator)
- assertEquals(TalerAmount(amount), balance.amount)
+ val fmt = "${if (balance.credit_debit_indicator == CreditDebitInfo.debit) '-' else '+'}${balance.amount}"
+ assertEquals(amount, fmt, "For $account")
}
}
+/** Perform a bank transaction of [amount] [from] account [to] account with [subject} */
suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String, subject: String = "payout"): Long {
return client.post("/accounts/$from/transactions") {
basicAuth("$from", "$from-password")
@@ -142,6 +145,7 @@ suspend fun ApplicationTestBuilder.tx(from: String, amount: String, to: String,
}.assertOkJson<TransactionCreateResponse>().row_id
}
+/** Perform a taler outgoing transaction of [amount] from exchange to merchant */
suspend fun ApplicationTestBuilder.transfer(amount: String) {
client.post("/accounts/exchange/taler-wire-gateway/transfer") {
pwAuth("exchange")
@@ -155,6 +159,7 @@ suspend fun ApplicationTestBuilder.transfer(amount: String) {
}.assertOk()
}
+/** Perform a taler incoming transaction of [amount] from merchant to exchange */
suspend fun ApplicationTestBuilder.addIncoming(amount: String) {
client.post("/accounts/exchange/taler-wire-gateway/admin/add-incoming") {
pwAuth("admin")
@@ -166,13 +171,27 @@ suspend fun ApplicationTestBuilder.addIncoming(amount: String) {
}.assertOk()
}
+/** Perform a cashout operation of [amount] from customer */
suspend fun ApplicationTestBuilder.cashout(amount: String) {
- client.postA("/accounts/customer/cashouts") {
+ val res = client.postA("/accounts/customer/cashouts") {
json {
"request_uid" to randShortHashCode()
"amount_debit" to amount
"amount_credit" to convert(amount)
}
+ }
+ if (res.status == HttpStatusCode.Conflict) {
+ // Retry with cashout info
+ fillCashoutInfo("customer")
+ client.postA("/accounts/customer/cashouts") {
+ json {
+ "request_uid" to randShortHashCode()
+ "amount_debit" to amount
+ "amount_credit" to convert(amount)
+ }
+ }
+ } else {
+ res
}.assertOkJson<CashoutPending> {
client.postA("/accounts/customer/cashouts/${it.cashout_id}/confirm") {
json { "tan" to smsCode("+99") }
@@ -180,6 +199,18 @@ suspend fun ApplicationTestBuilder.cashout(amount: String) {
}
}
+/** Perform a whithrawal operation of [amount] from customer */
+suspend fun ApplicationTestBuilder.withdrawal(amount: String) {
+ client.postA("/accounts/merchant/withdrawals") {
+ json { "amount" to amount }
+ }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+ val uuid = it.taler_withdraw_uri.split("/").last()
+ withdrawalSelect(uuid)
+ client.postA("/withdrawals/${uuid}/confirm")
+ .assertNoContent()
+ }
+}
+
suspend fun ApplicationTestBuilder.fillCashoutInfo(account: String) {
client.patchA("/accounts/$account") {
json {
diff --git a/util/src/main/kotlin/DB.kt b/util/src/main/kotlin/DB.kt
@@ -176,6 +176,19 @@ fun PreparedStatement.executeUpdateViolation(): Boolean {
}
}
+fun PreparedStatement.executeProcedureViolation(): Boolean {
+ val savepoint = connection.setSavepoint();
+ return try {
+ executeUpdate()
+ connection.releaseSavepoint(savepoint)
+ true
+ } catch (e: SQLException) {
+ connection.rollback(savepoint);
+ if (e.sqlState == "23505") return false // unique_violation
+ throw e // rethrowing, not to hide other types of errors.
+ }
+}
+
// sqlFilePrefix is, for example, "libeufin-bank" or "libeufin-nexus" (no trailing dash).
fun initializeDatabaseTables(cfg: DatabaseConfig, sqlFilePrefix: String) {
logger.info("doing DB initialization, sqldir ${cfg.sqlDir}, dbConnStr ${cfg.dbConnStr}")