diff options
author | Florian Dold <florian@dold.me> | 2024-01-22 21:29:47 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2024-01-22 21:29:47 +0100 |
commit | fb5f098f9e60f03cdd6f78aba5aa248ec5889485 (patch) | |
tree | 2ddbf078597d48cbf9c85fa3c0491e5e63bb2196 | |
parent | 88851f45403c1995c973bcae7ad2976db3c430c7 (diff) | |
download | wallet-core-fb5f098f9e60f03cdd6f78aba5aa248ec5889485.tar.xz |
wallet-core: implement and test balance reporting with scope info
-rw-r--r-- | packages/taler-harness/src/integrationtests/test-currency-scope.ts | 192 | ||||
-rw-r--r-- | packages/taler-harness/src/integrationtests/test-multiexchange.ts | 20 | ||||
-rw-r--r-- | packages/taler-harness/src/integrationtests/testrunner.ts | 4 | ||||
-rw-r--r-- | packages/taler-util/src/wallet-types.ts | 2 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 21 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/balance.ts | 333 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/common.ts | 51 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/exchanges.ts | 188 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/refresh.ts | 20 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/operations/withdraw.ts | 69 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.ts | 15 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/wallet.ts | 6 |
12 files changed, 699 insertions, 222 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts new file mode 100644 index 000000000..e07a8f47b --- /dev/null +++ b/packages/taler-harness/src/integrationtests/test-currency-scope.ts @@ -0,0 +1,192 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { Duration, j2s } from "@gnu-taler/taler-util"; +import { Wallet, WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; +import { + BankService, + ExchangeService, + GlobalTestState, + MerchantService, + generateRandomPayto, + setupDb, +} from "../harness/harness.js"; +import { + createWalletDaemonWithClient, + makeTestPaymentV2, + withdrawViaBankV2, +} from "../harness/helpers.js"; + +/** + * Run test for basic, bank-integrated withdrawal and payment. + */ +export async function runCurrencyScopeTest(t: GlobalTestState) { + // Set up test environment + const dbDefault = await setupDb(t); + + const dbExchangeTwo = await setupDb(t, { + nameSuffix: "exchange2", + }); + + const bank = await BankService.create(t, { + allowRegistrations: true, + currency: "TESTKUDOS", + database: dbDefault.connStr, + httpPort: 8082, + }); + + const exchangeOne = ExchangeService.create(t, { + name: "testexchange-1", + currency: "TESTKUDOS", + httpPort: 8081, + database: dbDefault.connStr, + }); + + const exchangeTwo = ExchangeService.create(t, { + name: "testexchange-2", + currency: "TESTKUDOS", + httpPort: 8281, + database: dbExchangeTwo.connStr, + }); + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + currency: "TESTKUDOS", + httpPort: 8083, + database: dbDefault.connStr, + }); + + const exchangeOneBankAccount = await bank.createExchangeAccount( + "myexchange", + "x", + ); + await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + + const exchangeTwoBankAccount = await bank.createExchangeAccount( + "myexchange2", + "x", + ); + await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + + bank.setSuggestedExchange( + exchangeOne, + exchangeOneBankAccount.accountPaytoUri, + ); + + await bank.start(); + + await bank.pingUntilAvailable(); + + // Set up the first exchange + + exchangeOne.addOfferedCoins(defaultCoinConfig); + await exchangeOne.start(); + await exchangeOne.pingUntilAvailable(); + + // Set up the second exchange + + exchangeTwo.addOfferedCoins(defaultCoinConfig); + await exchangeTwo.start(); + await exchangeTwo.pingUntilAvailable(); + + // Start and configure merchant + + merchant.addExchange(exchangeOne); + merchant.addExchange(exchangeTwo); + + await merchant.start(); + await merchant.pingUntilAvailable(); + + await merchant.addInstanceWithWireAccount({ + id: "default", + name: "Default Instance", + paytoUris: [generateRandomPayto("merchant-default")], + defaultWireTransferDelay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 1 }), + ), + }); + + await merchant.addInstanceWithWireAccount({ + id: "minst1", + name: "minst1", + paytoUris: [generateRandomPayto("minst1")], + defaultWireTransferDelay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 1 }), + ), + }); + + const { walletClient } = await createWalletDaemonWithClient(t, { + name: "wallet", + }); + + console.log("setup done!"); + + // Withdraw digital cash into the wallet. + + const w1 = await withdrawViaBankV2(t, { + walletClient, + bank, + exchange: exchangeOne, + amount: "TESTKUDOS:6", + }); + + const w2 = await withdrawViaBankV2(t, { + walletClient, + bank, + exchange: exchangeTwo, + amount: "TESTKUDOS:6", + }); + + await w1.withdrawalFinishedCond; + await w2.withdrawalFinishedCond; + + const bal = await walletClient.call(WalletApiOperation.GetBalances, {}); + console.log(j2s(bal)); + + // Separate balances, exchange-scope. + t.assertDeepEqual(bal.balances.length, 2); + + await walletClient.call(WalletApiOperation.AddGlobalCurrencyExchange, { + currency: "TESTKUDOS", + exchangeBaseUrl: exchangeOne.baseUrl, + exchangeMasterPub: exchangeOne.masterPub, + }); + + await walletClient.call(WalletApiOperation.AddGlobalCurrencyExchange, { + currency: "TESTKUDOS", + exchangeBaseUrl: exchangeTwo.baseUrl, + exchangeMasterPub: exchangeTwo.masterPub, + }); + + const ex = walletClient.call( + WalletApiOperation.ListGlobalCurrencyExchanges, + {}, + ); + console.log("global currency exchanges:"); + console.log(j2s(ex)); + + const bal2 = await walletClient.call(WalletApiOperation.GetBalances, {}); + console.log(j2s(bal2)); + + // Global currencies are merged + t.assertDeepEqual(bal2.balances.length, 1); +} + +runCurrencyScopeTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts index aeda035a8..e27bccc46 100644 --- a/packages/taler-harness/src/integrationtests/test-multiexchange.ts +++ b/packages/taler-harness/src/integrationtests/test-multiexchange.ts @@ -17,7 +17,9 @@ /** * Imports. */ +import { Duration } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { defaultCoinConfig } from "../harness/denomStructures.js"; import { BankService, ExchangeService, @@ -27,13 +29,10 @@ import { setupDb, } from "../harness/harness.js"; import { - createSimpleTestkudosEnvironmentV2, - withdrawViaBankV2, - makeTestPaymentV2, createWalletDaemonWithClient, + makeTestPaymentV2, + withdrawViaBankV2, } from "../harness/helpers.js"; -import { Duration, j2s } from "@gnu-taler/taler-util"; -import { defaultCoinConfig } from "../harness/denomStructures.js"; /** * Run test for basic, bank-integrated withdrawal and payment. @@ -146,14 +145,21 @@ export async function runMultiExchangeTest(t: GlobalTestState) { walletClient, bank, exchange: exchangeOne, - amount: "TESTKUDOS:20", + amount: "TESTKUDOS:6", + }); + + await withdrawViaBankV2(t, { + walletClient, + bank, + exchange: exchangeTwo, + amount: "TESTKUDOS:6", }); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); const order = { summary: "Buy me!", - amount: "TESTKUDOS:5", + amount: "TESTKUDOS:10", fulfillment_url: "taler://fulfillment-success/thx", }; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 6ab87c756..1b4bdc218 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -93,12 +93,13 @@ import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js"; import { runWithdrawalManualTest } from "./test-withdrawal-manual.js"; import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runLibeufinBankTest } from "./test-libeufin-bank.js"; -import { runMultiExchangeTest } from "./test-multiexchange.js"; +import { runCurrencyScopeTest } from "./test-currency-scope.js"; import { runAgeRestrictionsDepositTest } from "./test-age-restrictions-deposit.js"; import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js"; import { runPaymentDeletedTest } from "./test-payment-deleted.js"; import { runWithdrawalNotifyBeforeTxTest } from "./test-withdrawal-notify-before-tx.js"; import { runWalletDd48Test } from "./test-wallet-dd48.js"; +import { runMultiExchangeTest } from "./test-multiexchange.js"; /** * Test runner. @@ -187,6 +188,7 @@ const allTests: TestMainFunction[] = [ runLibeufinBankTest, runPaymentDeletedTest, runWalletDd48Test, + runCurrencyScopeTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts index c20290287..12231fb2d 100644 --- a/packages/taler-util/src/wallet-types.ts +++ b/packages/taler-util/src/wallet-types.ts @@ -1571,6 +1571,8 @@ export interface ExchangeWithdrawalDetails { * */ ageRestrictionOptions?: number[]; + + scopeInfo: ScopeInfo; } export interface GetExchangeTosResult { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index ceca24c82..149d73abc 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -30,6 +30,8 @@ import { import { AbsoluteTime, AgeCommitmentProof, + AmountJson, + AmountLike, AmountString, Amounts, AttentionInfo, @@ -1003,6 +1005,13 @@ export interface RefreshReasonDetails { proposalId?: string; } +export interface RefreshGroupPerExchangeInfo { + /** + * (Expected) output once the refresh group succeeded. + */ + outputEffective: AmountString; +} + /** * Group of refresh operations. The refreshed coins do not * have to belong to the same exchange, but must have the same @@ -1038,6 +1047,8 @@ export interface RefreshGroupRecord { expectedOutputPerCoin: AmountString[]; + infoPerExchange?: Record<string, RefreshGroupPerExchangeInfo>; + /** * Flag for each coin whether refreshing finished. * If a coin can't be refreshed (remaining value too small), @@ -1717,6 +1728,14 @@ export interface DepositTrackingInfo { exchangePub: string; } +export interface DepositInfoPerExchange { + /** + * Expected effective amount that will be deposited + * from coins of this exchange. + */ + amountEffective: AmountJson; +} + /** * Group of deposits made by the wallet. */ @@ -1768,6 +1787,8 @@ export interface DepositGroupRecord { statusPerCoin: DepositElementStatus[]; + infoPerExchange?: Record<string, DepositInfoPerExchange>; + /** * When the deposit transaction was aborted and * refreshes were tried, we create a refresh diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts index 53ca33fe7..a73476e9c 100644 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ b/packages/taler-wallet-core/src/operations/balance.ts @@ -54,6 +54,7 @@ import { AllowedAuditorInfo, AllowedExchangeInfo, AmountJson, + AmountLike, Amounts, BalanceFlag, BalancesResponse, @@ -61,6 +62,7 @@ import { GetBalanceDetailRequest, Logger, parsePaytoUri, + ScopeInfo, ScopeType, } from "@gnu-taler/taler-util"; import { @@ -68,6 +70,8 @@ import { OPERATION_STATUS_ACTIVE_FIRST, OPERATION_STATUS_ACTIVE_LAST, RefreshGroupRecord, + RefreshOperationStatus, + WalletDbReadOnlyTransactionArr, WalletStoresV1, WithdrawalGroupStatus, } from "../db.js"; @@ -75,7 +79,10 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { assertUnreachable } from "../util/assertUnreachable.js"; import { checkLogicInvariant } from "../util/invariants.js"; import { GetReadOnlyAccess } from "../util/query.js"; -import { getExchangeWireDetailsInTx } from "./exchanges.js"; +import { + getExchangeScopeInfo, + getExchangeWireDetailsInTx, +} from "./exchanges.js"; /** * Logger. @@ -83,6 +90,7 @@ import { getExchangeWireDetailsInTx } from "./exchanges.js"; const logger = new Logger("operations/balance.ts"); interface WalletBalance { + scopeInfo: ScopeInfo; available: AmountJson; pendingIncoming: AmountJson; pendingOutgoing: AmountJson; @@ -109,67 +117,216 @@ function computeRefreshGroupAvailableAmount(r: RefreshGroupRecord): AmountJson { return available; } -/** - * Get balance information. - */ -export async function getBalancesInsideTransaction( - ws: InternalWalletState, - tx: GetReadOnlyAccess<{ - coinAvailability: typeof WalletStoresV1.coinAvailability; - refreshGroups: typeof WalletStoresV1.refreshGroups; - withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; - depositGroups: typeof WalletStoresV1.depositGroups; - }>, -): Promise<BalancesResponse> { - const balanceStore: Record<string, WalletBalance> = {}; +function getBalanceKey(scopeInfo: ScopeInfo): string { + switch (scopeInfo.type) { + case ScopeType.Auditor: + return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`; + case ScopeType.Exchange: + return `${scopeInfo.type};${scopeInfo.currency};${scopeInfo.url}`; + case ScopeType.Global: + return `${scopeInfo.type};${scopeInfo.currency}`; + } +} + +class BalancesStore { + private exchangeScopeCache: Record<string, ScopeInfo> = {}; + private balanceStore: Record<string, WalletBalance> = {}; + + constructor( + private ws: InternalWalletState, + private tx: WalletDbReadOnlyTransactionArr< + [ + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "exchanges", + "exchangeDetails", + ] + >, + ) {} /** * Add amount to a balance field, both for * the slicing by exchange and currency. */ - const initBalance = (currency: string): WalletBalance => { - const b = balanceStore[currency]; + private async initBalance( + currency: string, + exchangeBaseUrl: string, + ): Promise<WalletBalance> { + let scopeInfo: ScopeInfo | undefined = + this.exchangeScopeCache[exchangeBaseUrl]; + if (!scopeInfo) { + scopeInfo = await getExchangeScopeInfo( + this.tx, + exchangeBaseUrl, + currency, + ); + this.exchangeScopeCache[exchangeBaseUrl] = scopeInfo; + } + const balanceKey = getBalanceKey(scopeInfo); + const b = this.balanceStore[balanceKey]; if (!b) { - balanceStore[currency] = { - available: Amounts.zeroOfCurrency(currency), - pendingIncoming: Amounts.zeroOfCurrency(currency), - pendingOutgoing: Amounts.zeroOfCurrency(currency), + const zero = Amounts.zeroOfCurrency(currency); + this.balanceStore[balanceKey] = { + scopeInfo, + available: zero, + pendingIncoming: zero, + pendingOutgoing: zero, flagIncomingAml: false, flagIncomingConfirmation: false, flagIncomingKyc: false, flagOutgoingKyc: false, }; } - return balanceStore[currency]; - }; + return this.balanceStore[balanceKey]; + } + + async addAvailable( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.available = Amounts.add(b.available, amount).amount; + } + + async addPendingIncoming( + currency: string, + exchangeBaseUrl: string, + amount: AmountLike, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.pendingIncoming = Amounts.add(b.available, amount).amount; + } + + async setFlagIncomingAml( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingAml = true; + } + + async setFlagIncomingKyc( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingKyc = true; + } + + async setFlagIncomingConfirmation( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagIncomingConfirmation = true; + } + + async setFlagOutgoingKyc( + currency: string, + exchangeBaseUrl: string, + ): Promise<void> { + const b = await this.initBalance(currency, exchangeBaseUrl); + b.flagOutgoingKyc = true; + } + + toBalancesResponse(): BalancesResponse { + const balancesResponse: BalancesResponse = { + balances: [], + }; + + const balanceStore = this.balanceStore; + + Object.keys(balanceStore) + .sort() + .forEach((c) => { + const v = balanceStore[c]; + const flags: BalanceFlag[] = []; + if (v.flagIncomingAml) { + flags.push(BalanceFlag.IncomingAml); + } + if (v.flagIncomingKyc) { + flags.push(BalanceFlag.IncomingKyc); + } + if (v.flagIncomingConfirmation) { + flags.push(BalanceFlag.IncomingConfirmation); + } + if (v.flagOutgoingKyc) { + flags.push(BalanceFlag.OutgoingKyc); + } + balancesResponse.balances.push({ + scopeInfo: v.scopeInfo, + available: Amounts.stringify(v.available), + pendingIncoming: Amounts.stringify(v.pendingIncoming), + pendingOutgoing: Amounts.stringify(v.pendingOutgoing), + // FIXME: This field is basically not implemented, do we even need it? + hasPendingTransactions: false, + // FIXME: This field is basically not implemented, do we even need it? + requiresUserInput: false, + flags, + }); + }); + return balancesResponse; + } +} + +/** + * Get balance information. + */ +export async function getBalancesInsideTransaction( + ws: InternalWalletState, + tx: WalletDbReadOnlyTransactionArr< + [ + "exchanges", + "exchangeDetails", + "coinAvailability", + "refreshGroups", + "depositGroups", + "withdrawalGroups", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ] + >, +): Promise<BalancesResponse> { + const balanceStore: BalancesStore = new BalancesStore(ws, tx); const keyRangeActive = GlobalIDB.KeyRange.bound( OPERATION_STATUS_ACTIVE_FIRST, OPERATION_STATUS_ACTIVE_LAST, ); - await tx.coinAvailability.iter().forEach((ca) => { - const b = initBalance(ca.currency); + await tx.coinAvailability.iter().forEachAsync(async (ca) => { const count = ca.visibleCoinCount ?? 0; for (let i = 0; i < count; i++) { - b.available = Amounts.add(b.available, ca.value).amount; + await balanceStore.addAvailable( + ca.currency, + ca.exchangeBaseUrl, + ca.value, + ); } }); - await tx.refreshGroups.iter().forEach((r) => { - const b = initBalance(r.currency); - b.available = Amounts.add( - b.available, - computeRefreshGroupAvailableAmount(r), - ).amount; + await tx.refreshGroups.iter().forEachAsync(async (r) => { + switch (r.operationStatus) { + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + default: + return; + } + const perExchange = r.infoPerExchange; + if (!perExchange) { + return; + } + for (const [e, x] of Object.entries(perExchange)) { + await balanceStore.addAvailable(r.currency, e, x.outputEffective); + } }); await tx.withdrawalGroups.indexes.byStatus .iter(keyRangeActive) - .forEach((wgRecord) => { - const b = initBalance( - Amounts.currencyOf(wgRecord.denomsSel.totalWithdrawCost), - ); + .forEachAsync(async (wgRecord) => { + const currency = Amounts.currencyOf(wgRecord.denomsSel.totalCoinValue); switch (wgRecord.status) { case WithdrawalGroupStatus.AbortedBank: case WithdrawalGroupStatus.AbortedExchange: @@ -190,76 +347,53 @@ export async function getBalancesInsideTransaction( break; case WithdrawalGroupStatus.SuspendedKyc: case WithdrawalGroupStatus.PendingKyc: - b.flagIncomingKyc = true; + await balanceStore.setFlagIncomingKyc( + currency, + wgRecord.exchangeBaseUrl, + ); break; case WithdrawalGroupStatus.PendingAml: case WithdrawalGroupStatus.SuspendedAml: - b.flagIncomingAml = true; + await balanceStore.setFlagIncomingAml( + currency, + wgRecord.exchangeBaseUrl, + ); break; case WithdrawalGroupStatus.PendingRegisteringBank: case WithdrawalGroupStatus.PendingWaitConfirmBank: - b.flagIncomingConfirmation = true; + await balanceStore.setFlagIncomingConfirmation( + currency, + wgRecord.exchangeBaseUrl, + ); break; default: assertUnreachable(wgRecord.status); } - b.pendingIncoming = Amounts.add( - b.pendingIncoming, + await balanceStore.addPendingIncoming( + currency, + wgRecord.exchangeBaseUrl, wgRecord.denomsSel.totalCoinValue, - ).amount; + ); }); - // FIXME: Use indexing to filter out final transactions. await tx.depositGroups.indexes.byStatus .iter(keyRangeActive) - .forEach((dgRecord) => { - const b = initBalance(Amounts.currencyOf(dgRecord.amount)); - switch (dgRecord.operationStatus) { - case DepositOperationStatus.SuspendedKyc: - case DepositOperationStatus.PendingKyc: - b.flagOutgoingKyc = true; + .forEachAsync(async (dgRecord) => { + const perExchange = dgRecord.infoPerExchange; + if (!perExchange) { + return; } - }); - - const balancesResponse: BalancesResponse = { - balances: [], - }; - - Object.keys(balanceStore) - .sort() - .forEach((c) => { - const v = balanceStore[c]; - const flags: BalanceFlag[] = []; - if (v.flagIncomingAml) { - flags.push(BalanceFlag.IncomingAml); - } - if (v.flagIncomingKyc) { - flags.push(BalanceFlag.IncomingKyc); - } - if (v.flagIncomingConfirmation) { - flags.push(BalanceFlag.IncomingConfirmation); - } - if (v.flagOutgoingKyc) { - flags.push(BalanceFlag.OutgoingKyc); + for (const [e, x] of Object.entries(perExchange)) { + const currency = Amounts.currencyOf(dgRecord.amount); + switch (dgRecord.operationStatus) { + case DepositOperationStatus.SuspendedKyc: + case DepositOperationStatus.PendingKyc: + await balanceStore.setFlagOutgoingKyc(currency, e); + } } - balancesResponse.balances.push({ - scopeInfo: { - // FIXME: obtain REAL scopeInfo instead of faking a global currency - type: ScopeType.Global, - currency: Amounts.currencyOf(v.available), - }, - available: Amounts.stringify(v.available), - pendingIncoming: Amounts.stringify(v.pendingIncoming), - pendingOutgoing: Amounts.stringify(v.pendingOutgoing), - // FIXME: This field is basically not implemented, do we even need it? - hasPendingTransactions: false, - // FIXME: This field is basically not implemented, do we even need it? - requiresUserInput: false, - flags, - }); }); - return balancesResponse; + return balanceStore.toBalancesResponse(); } /** @@ -270,18 +404,23 @@ export async function getBalances( ): Promise<BalancesResponse> { logger.trace("starting to compute balance"); - const wbal = await ws.db - .mktx((x) => [ - x.coins, - x.coinAvailability, - x.refreshGroups, - x.purchases, - x.withdrawalGroups, - x.depositGroups, - ]) - .runReadOnly(async (tx) => { + const wbal = await ws.db.runReadWriteTx( + [ + "coinAvailability", + "coins", + "depositGroups", + "exchangeDetails", + "exchanges", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + "purchases", + "refreshGroups", + "withdrawalGroups", + ], + async (tx) => { return getBalancesInsideTransaction(ws, tx); - }); + }, + ); logger.trace("finished computing wallet balance"); diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts index f34190cef..d626f0056 100644 --- a/packages/taler-wallet-core/src/operations/common.ts +++ b/packages/taler-wallet-core/src/operations/common.ts @@ -19,7 +19,6 @@ */ import { AbsoluteTime, - AgeRestriction, AmountJson, Amounts, CancellationToken, @@ -28,7 +27,6 @@ import { Duration, ExchangeEntryState, ExchangeEntryStatus, - ExchangeListItem, ExchangeTosStatus, ExchangeUpdateStatus, getErrorDetailFromException, @@ -36,10 +34,7 @@ import { Logger, makeErrorDetail, NotificationType, - OperationErrorInfo, RefreshReason, - ScopeInfo, - ScopeType, TalerError, TalerErrorCode, TalerErrorDetail, @@ -55,7 +50,6 @@ import { CoinRecord, DbPreciseTimestamp, DepositGroupRecord, - ExchangeDetailsRecord, ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, ExchangeEntryRecord, @@ -653,51 +647,6 @@ export function getExchangeState(r: ExchangeEntryRecord): ExchangeEntryState { }; } -/** - * Mock scope info for an exchange by always returning a regional currency scope. - */ -function mockExchangeScopeInfo( - r: ExchangeEntryRecord, - exchangeDetails: ExchangeDetailsRecord | undefined, -): ScopeInfo | undefined { - const currency = r.presetCurrencyHint ?? exchangeDetails?.currency; - if (currency) { - return { - currency, - type: ScopeType.Exchange, - url: r.baseUrl, - }; - } - return undefined; -} - -export function makeExchangeListItem( - r: ExchangeEntryRecord, - exchangeDetails: ExchangeDetailsRecord | undefined, - lastError: TalerErrorDetail | undefined, -): ExchangeListItem { - const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError - ? { - error: lastError, - } - : undefined; - - return { - exchangeBaseUrl: r.baseUrl, - currency: exchangeDetails?.currency ?? r.presetCurrencyHint, - exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), - exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), - tosStatus: getExchangeTosStatusFromRecord(r), - ageRestrictionOptions: exchangeDetails?.ageMask - ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask) - : [], - paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [], - lastUpdateErrorInfo, - // FIXME: Return real scope info in the future! - scopeInfo: mockExchangeScopeInfo(r, exchangeDetails), - }; -} - export interface LongpollResult { ready: boolean; } diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 67404665c..b4d45db2c 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -25,6 +25,7 @@ */ import { AbsoluteTime, + AgeRestriction, Amounts, CancellationToken, DeleteExchangeRequest, @@ -50,7 +51,10 @@ import { LibtoolVersion, Logger, NotificationType, + OperationErrorInfo, Recoup, + ScopeInfo, + ScopeType, TalerError, TalerErrorCode, TalerErrorDetail, @@ -88,7 +92,7 @@ import { ExchangeEntryDbRecordStatus, ExchangeEntryDbUpdateStatus, PendingTaskType, - WalletDbReadWriteTransaction, + WalletDbReadOnlyTransactionArr, WalletDbReadWriteTransactionArr, createTimeline, isWithdrawableDenom, @@ -103,7 +107,6 @@ import { import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; import { - DbReadOnlyTransaction, DbReadOnlyTransactionArr, GetReadOnlyAccess, GetReadWriteAccess, @@ -114,9 +117,10 @@ import { TaskRunResult, TaskRunResultType, constructTaskIdentifier, + getExchangeEntryStatusFromRecord, getExchangeState, getExchangeTosStatusFromRecord, - makeExchangeListItem, + getExchangeUpdateStatusFromRecord, runTaskWithErrorReporting, } from "./common.js"; @@ -206,6 +210,105 @@ async function getExchangeRecordsInternal( ]); } +export async function getExchangeScopeInfo( + tx: WalletDbReadOnlyTransactionArr< + [ + "exchanges", + "exchangeDetails", + "globalCurrencyExchanges", + "globalCurrencyAuditors", + ] + >, + exchangeBaseUrl: string, + currency: string, +): Promise<ScopeInfo> { + const det = await getExchangeRecordsInternal(tx, exchangeBaseUrl); + if (!det) { + return { + type: ScopeType.Exchange, + currency: currency, + url: exchangeBaseUrl, + }; + } + return internalGetExchangeScopeInfo(tx, det); +} + +async function internalGetExchangeScopeInfo( + tx: WalletDbReadOnlyTransactionArr< + ["globalCurrencyExchanges", "globalCurrencyAuditors"] + >, + exchangeDetails: ExchangeDetailsRecord, +): Promise<ScopeInfo> { + const globalExchangeRec = + await tx.globalCurrencyExchanges.indexes.byCurrencyAndUrlAndPub.get([ + exchangeDetails.currency, + exchangeDetails.exchangeBaseUrl, + exchangeDetails.masterPublicKey, + ]); + if (globalExchangeRec) { + return { + currency: exchangeDetails.currency, + type: ScopeType.Global, + }; + } else { + for (const aud of exchangeDetails.auditors) { + const globalAuditorRec = + await tx.globalCurrencyAuditors.indexes.byCurrencyAndUrlAndPub.get([ + exchangeDetails.currency, + aud.auditor_url, + aud.auditor_pub, + ]); + if (globalAuditorRec) { + return { + currency: exchangeDetails.currency, + type: ScopeType.Auditor, + url: aud.auditor_url, + }; + } + } + } + return { + currency: exchangeDetails.currency, + type: ScopeType.Exchange, + url: exchangeDetails.exchangeBaseUrl, + }; +} + +async function makeExchangeListItem( + tx: WalletDbReadOnlyTransactionArr< + ["globalCurrencyExchanges", "globalCurrencyAuditors"] + >, + r: ExchangeEntryRecord, + exchangeDetails: ExchangeDetailsRecord | undefined, + lastError: TalerErrorDetail | undefined, +): Promise<ExchangeListItem> { + const lastUpdateErrorInfo: OperationErrorInfo | undefined = lastError + ? { + error: lastError, + } + : undefined; + + let scopeInfo: ScopeInfo | undefined = undefined; + + if (exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); + } + + return { + exchangeBaseUrl: r.baseUrl, + currency: exchangeDetails?.currency ?? r.presetCurrencyHint, + exchangeUpdateStatus: getExchangeUpdateStatusFromRecord(r), + exchangeEntryStatus: getExchangeEntryStatusFromRecord(r), + tosStatus: getExchangeTosStatusFromRecord(r), + ageRestrictionOptions: exchangeDetails?.ageMask + ? AgeRestriction.getAgeGroupsFromMask(exchangeDetails.ageMask) + : [], + paytoUris: exchangeDetails?.wireInfo.accounts.map((x) => x.payto_uri) ?? [], + lastUpdateErrorInfo, + scopeInfo, + }; +} + export interface ExchangeWireDetails { currency: string; masterPublicKey: EddsaPublicKeyString; @@ -240,14 +343,15 @@ export async function lookupExchangeByUri( ws: InternalWalletState, req: GetExchangeEntryByUrlRequest, ): Promise<ExchangeListItem> { - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.operationRetries, - ]) - .runReadOnly(async (tx) => { + return await ws.db.runReadOnlyTx( + [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + async (tx) => { const exchangeRec = await tx.exchanges.get(req.exchangeBaseUrl); if (!exchangeRec) { throw Error("exchange not found"); @@ -259,12 +363,14 @@ export async function lookupExchangeByUri( const opRetryRecord = await tx.operationRetries.get( TaskIdentifiers.forExchangeUpdate(exchangeRec), ); - return makeExchangeListItem( + return await makeExchangeListItem( + tx, exchangeRec, exchangeDetails, opRetryRecord?.lastError, ); - }); + }, + ); } /** @@ -800,6 +906,7 @@ export interface ReadyExchangeSummary { wireInfo: WireInfo; protocolVersionRange: string; tosAcceptedTimestamp: TalerPreciseTimestamp | undefined; + scopeInfo: ScopeInfo; } /** @@ -863,14 +970,26 @@ export async function fetchFreshExchange( ); } - const { exchange, exchangeDetails, retryInfo } = await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries]) - .runReadOnly(async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeRecordsInternal(tx, canonUrl); - const retryInfo = await tx.operationRetries.get(operationId); - return { exchange, exchangeDetails, retryInfo }; - }); + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await ws.db.runReadOnlyTx( + [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + async (tx) => { + const exchange = await tx.exchanges.get(canonUrl); + const exchangeDetails = await getExchangeRecordsInternal(tx, canonUrl); + const retryInfo = await tx.operationRetries.get(operationId); + let scopeInfo: ScopeInfo | undefined = undefined; + if (exchange && exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); + } + return { exchange, exchangeDetails, retryInfo, scopeInfo }; + }, + ); if (!exchange) { throw Error("exchange entry does not exist anymore"); @@ -891,6 +1010,10 @@ export async function fetchFreshExchange( throw Error("invariant failed"); } + if (!scopeInfo) { + throw Error("invariant failed"); + } + const res: ReadyExchangeSummary = { currency: exchangeDetails.currency, exchangeBaseUrl: canonUrl, @@ -903,6 +1026,7 @@ export async function fetchFreshExchange( tosAcceptedTimestamp: timestampOptionalPreciseFromDb( exchange.tosAcceptedTimestamp, ), + scopeInfo, }; if (options.expectedMasterPub) { @@ -1309,9 +1433,15 @@ export async function listExchanges( ws: InternalWalletState, ): Promise<ExchangesListResponse> { const exchanges: ExchangeListItem[] = []; - await ws.db - .mktx((x) => [x.exchanges, x.exchangeDetails, x.operationRetries]) - .runReadOnly(async (tx) => { + await ws.db.runReadOnlyTx( + [ + "exchanges", + "operationRetries", + "exchangeDetails", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + async (tx) => { const exchangeRecords = await tx.exchanges.iter().toArray(); for (const r of exchangeRecords) { const taskId = constructTaskIdentifier({ @@ -1321,10 +1451,16 @@ export async function listExchanges( const exchangeDetails = await getExchangeRecordsInternal(tx, r.baseUrl); const opRetryRecord = await tx.operationRetries.get(taskId); exchanges.push( - makeExchangeListItem(r, exchangeDetails, opRetryRecord?.lastError), + await makeExchangeListItem( + tx, + r, + exchangeDetails, + opRetryRecord?.lastError, + ), ); } - }); + }, + ); return { exchanges }; } diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index d49c9a1cf..974eb1619 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -80,6 +80,7 @@ import { } from "../db.js"; import { getCandidateWithdrawalDenomsTx, + RefreshGroupPerExchangeInfo, RefreshSessionRecord, timestampPreciseToDb, timestampProtocolFromDb, @@ -1107,6 +1108,7 @@ async function processRefreshSession( export interface RefreshOutputInfo { outputPerCoin: AmountJson[]; + perExchangeInfo: Record<string, RefreshGroupPerExchangeInfo>; } export async function calculateRefreshOutput( @@ -1124,6 +1126,8 @@ export async function calculateRefreshOutput( const denomsPerExchange: Record<string, DenominationRecord[]> = {}; + const infoPerExchange: Record<string, RefreshGroupPerExchangeInfo> = {}; + // FIXME: Use denom groups instead of querying all denominations! const getDenoms = async ( exchangeBaseUrl: string, @@ -1163,11 +1167,21 @@ export async function calculateRefreshOutput( ws.config.testing.denomselAllowLate, ); const output = Amounts.sub(refreshAmount, cost).amount; + let exchInfo = infoPerExchange[coin.exchangeBaseUrl]; + if (!exchInfo) { + infoPerExchange[coin.exchangeBaseUrl] = exchInfo = { + outputEffective: Amounts.stringify(Amounts.zeroOfAmount(cost)), + }; + } + exchInfo.outputEffective = Amounts.stringify( + Amounts.add(exchInfo.outputEffective, output).amount, + ); estimatedOutputPerCoin.push(output); } return { outputPerCoin: estimatedOutputPerCoin, + perExchangeInfo: infoPerExchange, }; } @@ -1234,6 +1248,10 @@ async function applyRefresh( } } +export interface CreateRefreshGroupResult { + refreshGroupId: string; +} + /** * Create a refresh group for a list of coins. * @@ -1252,7 +1270,7 @@ export async function createRefreshGroup( oldCoinPubs: CoinRefreshRequest[], reason: RefreshReason, reasonDetails?: RefreshReasonDetails, -): Promise<RefreshGroupId> { +): Promise<CreateRefreshGroupResult> { const refreshGroupId = encodeCrock(getRandomBytes(32)); const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs); diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 29c2cae40..d198cf482 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -557,9 +557,12 @@ export async function getBankWithdrawalInfo( throw Error(`can't parse URL ${talerWithdrawUri}`); } - const bankApi = new TalerBankIntegrationHttpClient(uriResult.bankIntegrationApiBaseUrl, http); + const bankApi = new TalerBankIntegrationHttpClient( + uriResult.bankIntegrationApiBaseUrl, + http, + ); - const { body: config } = await bankApi.getConfig() + const { body: config } = await bankApi.getConfig(); if (!bankApi.isCompatible(config.version)) { throw TalerError.fromDetail( @@ -572,12 +575,14 @@ export async function getBankWithdrawalInfo( ); } - const resp = await bankApi.getWithdrawalOperationById(uriResult.withdrawalOperationId) + const resp = await bankApi.getWithdrawalOperationById( + uriResult.withdrawalOperationId, + ); if (resp.type === "fail") { throw TalerError.fromUncheckedDetail(resp.detail); } - const { body: status } = resp + const { body: status } = resp; logger.info(`bank withdrawal operation status: ${j2s(status)}`); @@ -1214,7 +1219,8 @@ export async function updateWithdrawalDenoms( denom.verificationStatus === DenominationVerificationStatus.Unverified ) { logger.trace( - `Validating denomination (${current + 1}/${denominations.length + `Validating denomination (${current + 1}/${ + denominations.length }) signature of ${denom.denomPubHash}`, ); let valid = false; @@ -1859,7 +1865,7 @@ export async function getExchangeWithdrawalInfo( ) { logger.warn( `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchange.protocolVersionRange}), checking for updates`, + `(exchange has ${exchange.protocolVersionRange}), checking for updates`, ); } } @@ -1896,6 +1902,7 @@ export async function getExchangeWithdrawalInfo( ageRestrictionOptions: hasDenomWithAgeRestriction ? AGE_MASK_GROUPS : undefined, + scopeInfo: exchange.scopeInfo, }; return ret; } @@ -1907,9 +1914,8 @@ export interface GetWithdrawalDetailsForUriOpts { type WithdrawalOperationMemoryMap = { [uri: string]: boolean | undefined; -} -const ongoingChecks: WithdrawalOperationMemoryMap = { -} +}; +const ongoingChecks: WithdrawalOperationMemoryMap = {}; /** * Get more information about a taler://withdraw URI. * @@ -1950,28 +1956,41 @@ export async function getWithdrawalDetailsForUri( ); }); - // FIXME: this should be removed after the extended version of + // FIXME: this should be removed after the extended version of // withdrawal state machine. issue #8099 - if (info.status === "pending" && opts.notifyChangeFromPendingTimeoutMs !== undefined && !ongoingChecks[talerWithdrawUri]) { + if ( + info.status === "pending" && + opts.notifyChangeFromPendingTimeoutMs !== undefined && + !ongoingChecks[talerWithdrawUri] + ) { ongoingChecks[talerWithdrawUri] = true; - const bankApi = new TalerBankIntegrationHttpClient(info.apiBaseUrl, ws.http); + const bankApi = new TalerBankIntegrationHttpClient( + info.apiBaseUrl, + ws.http, + ); console.log( `waiting operation (${info.operationId}) to change from pending`, ); - bankApi.getWithdrawalOperationById(info.operationId, { - old_state: "pending", - timeoutMs: opts.notifyChangeFromPendingTimeoutMs - }).then(resp => { - console.log( - `operation (${info.operationId}) to change to ${JSON.stringify(resp, undefined, 2)}`, - ); - ws.notify({ - type: NotificationType.WithdrawalOperationTransition, - operationId: info.operationId, - state: resp.type === "fail" ? info.status : resp.body.status, + bankApi + .getWithdrawalOperationById(info.operationId, { + old_state: "pending", + timeoutMs: opts.notifyChangeFromPendingTimeoutMs, + }) + .then((resp) => { + console.log( + `operation (${info.operationId}) to change to ${JSON.stringify( + resp, + undefined, + 2, + )}`, + ); + ws.notify({ + type: NotificationType.WithdrawalOperationTransition, + operationId: info.operationId, + state: resp.type === "fail" ? info.status : resp.body.status, + }); + ongoingChecks[talerWithdrawUri] = false; }); - ongoingChecks[talerWithdrawUri] = false - }) } return { diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index d75450a64..e06d7454b 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -602,14 +602,9 @@ async function selectPayMerchantCandidates( ws: InternalWalletState, req: SelectPayCoinRequestNg, ): Promise<[AvailableDenom[], Record<string, AmountJson>]> { - return await ws.db - .mktx((x) => [ - x.exchanges, - x.exchangeDetails, - x.denominations, - x.coinAvailability, - ]) - .runReadOnly(async (tx) => { + return await ws.db.runReadOnlyTx( + ["exchanges", "exchangeDetails", "denominations", "coinAvailability"], + async (tx) => { // FIXME: Use the existing helper (from balance.ts) to // get acceptable exchanges. const denoms: AvailableDenom[] = []; @@ -721,6 +716,7 @@ async function selectPayMerchantCandidates( }); } } + logger.info(`available denoms ${j2s(denoms)}`); // Sort by available amount (descending), deposit fee (ascending) and // denomPub (ascending) if deposit fee is the same // (to guarantee deterministic results) @@ -731,7 +727,8 @@ async function selectPayMerchantCandidates( strcmp(o1.denomPubHash, o2.denomPubHash), ); return [denoms, wfPerExchange]; - }); + }, + ); } /** diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 87c5aa995..333e42621 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -1048,11 +1048,7 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>( withdrawalAccountsList: wi.exchangeCreditAccountDetails, numCoins, // FIXME: Once we have proper scope info support, return correct info here. - scopeInfo: { - type: ScopeType.Exchange, - currency: amt.currency, - url: req.exchangeBaseUrl, - }, + scopeInfo: wi.scopeInfo, }; return resp; } |