diff options
Diffstat (limited to 'packages')
-rw-r--r-- | packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts (renamed from packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts) | 44 | ||||
-rw-r--r-- | packages/taler-harness/src/integrationtests/testrunner.ts | 4 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/db.ts | 6 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/deposits.ts | 230 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/dev-experiments.ts | 96 | ||||
-rw-r--r-- | packages/taler-wallet-core/src/transactions.ts | 29 |
6 files changed, 282 insertions, 127 deletions
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts index 4662c5110..cb9c54f1d 100644 --- a/packages/taler-harness/src/integrationtests/test-wallet-refresh-blocked.ts +++ b/packages/taler-harness/src/integrationtests/test-wallet-blocked-deposit.ts @@ -17,10 +17,16 @@ /** * Imports. */ -import { AmountString, j2s } from "@gnu-taler/taler-util"; +import { + AmountString, + NotificationType, + TransactionMajorState, + TransactionMinorState, + j2s, +} from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig } from "../harness/denomStructures.js"; -import { GlobalTestState } from "../harness/harness.js"; +import { GlobalTestState, generateRandomPayto } from "../harness/harness.js"; import { createSimpleTestkudosEnvironmentV2, createWalletDaemonWithClient, @@ -43,7 +49,7 @@ const coinCommon = { /** * Run test for refreshe after a payment. */ -export async function runWalletRefreshBlockedTest(t: GlobalTestState) { +export async function runWalletBlockedDeposit(t: GlobalTestState) { // Set up test environment const coinConfigList: CoinConfig[] = [ @@ -66,6 +72,7 @@ export async function runWalletRefreshBlockedTest(t: GlobalTestState) { const { walletClient: w1 } = await createWalletDaemonWithClient(t, { name: "w1", + persistent: true, config: { testing: { devModeActive: true, @@ -97,6 +104,8 @@ export async function runWalletRefreshBlockedTest(t: GlobalTestState) { }, }); + const userPayto = generateRandomPayto("foo"); + const bal = await w1.call(WalletApiOperation.GetBalances, {}); console.log(`balance: ${j2s(bal)}`); @@ -109,12 +118,35 @@ export async function runWalletRefreshBlockedTest(t: GlobalTestState) { const depositCheckResp = await w1.call(WalletApiOperation.PrepareDeposit, { amount: "TESTKUDOS:18" as AmountString, - depositPaytoUri: "payto://x-taler-bank/localhost/myuser", + depositPaytoUri: userPayto, }); console.log(`check resp: ${j2s(depositCheckResp)}`); - // t.assertTrue(false); + const depositCreateResp = await w1.call( + WalletApiOperation.CreateDepositGroup, + { + amount: "TESTKUDOS:18" as AmountString, + depositPaytoUri: userPayto, + }, + ); + + console.log(`create resp: ${j2s(depositCreateResp)}`); + + const depositTrackCond = w1.waitForNotificationCond((n) => { + return ( + n.type === NotificationType.TransactionStateTransition && + n.transactionId === depositCreateResp.transactionId && + n.newTxState.major === TransactionMajorState.Pending && + n.newTxState.minor === TransactionMinorState.Track + ); + }); + + await w1.call(WalletApiOperation.ApplyDevExperiment, { + devExperimentUri: "taler://dev-experiment/stop-block-refresh", + }); + + await depositTrackCond; } -runWalletRefreshBlockedTest.suites = ["wallet"]; +runWalletBlockedDeposit.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts index 2bca91e45..063aefa43 100644 --- a/packages/taler-harness/src/integrationtests/testrunner.ts +++ b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -101,7 +101,7 @@ import { runWalletGenDbTest } from "./test-wallet-gendb.js"; import { runWalletInsufficientBalanceTest } from "./test-wallet-insufficient-balance.js"; import { runWalletNotificationsTest } from "./test-wallet-notifications.js"; import { runWalletObservabilityTest } from "./test-wallet-observability.js"; -import { runWalletRefreshBlockedTest } from "./test-wallet-refresh-blocked.js"; +import { runWalletBlockedDeposit } from "./test-wallet-blocked-deposit.js"; import { runWalletRefreshTest } from "./test-wallet-refresh.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; import { runWallettestingTest } from "./test-wallettesting.js"; @@ -213,7 +213,7 @@ const allTests: TestMainFunction[] = [ runWalletWirefeesTest, runDenomLostTest, runWalletDenomExpireTest, - runWalletRefreshBlockedTest, + runWalletBlockedDeposit, ]; export interface TestRunSpec { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index de22d78a8..7b9dfa2a2 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1787,9 +1787,9 @@ export interface DepositGroupRecord { contractTermsHash: string; - payCoinSelection: DbCoinSelection; + payCoinSelection?: DbCoinSelection; - payCoinSelectionUid: string; + payCoinSelectionUid?: string; totalPayCost: AmountString; @@ -1804,7 +1804,7 @@ export interface DepositGroupRecord { operationStatus: DepositOperationStatus; - statusPerCoin: DepositElementStatus[]; + statusPerCoin?: DepositElementStatus[]; infoPerExchange?: Record<string, DepositInfoPerExchange>; diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts index 05a5d780a..5b23d8325 100644 --- a/packages/taler-wallet-core/src/deposits.ts +++ b/packages/taler-wallet-core/src/deposits.ts @@ -413,16 +413,28 @@ async function refundDepositGroup( wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { - const newTxPerCoin = [...depositGroup.statusPerCoin]; + const statusPerCoin = depositGroup.statusPerCoin; + const payCoinSelection = depositGroup.payCoinSelection; + if (!statusPerCoin) { + throw Error( + "unable to refund deposit group without coin selection (status missing)", + ); + } + if (!payCoinSelection) { + throw Error( + "unable to refund deposit group without coin selection (selection missing)", + ); + } + const newTxPerCoin = [...statusPerCoin]; logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`); - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - const st = depositGroup.statusPerCoin[i]; + for (let i = 0; i < statusPerCoin.length; i++) { + const st = statusPerCoin[i]; switch (st) { case DepositElementStatus.RefundFailed: case DepositElementStatus.RefundSuccess: break; default: { - const coinPub = depositGroup.payCoinSelection.coinPubs[i]; + const coinPub = payCoinSelection.coinPubs[i]; const coinExchange = await wex.db.runReadOnlyTx( ["coins"], async (tx) => { @@ -431,7 +443,7 @@ async function refundDepositGroup( return coinRecord.exchangeBaseUrl; }, ); - const refundAmount = depositGroup.payCoinSelection.coinContributions[i]; + const refundAmount = payCoinSelection.coinContributions[i]; // We use a constant refund transaction ID, since there can // only be one refund. const rtid = 1; @@ -503,8 +515,8 @@ async function refundDepositGroup( const refreshCoins: CoinRefreshRequest[] = []; for (let i = 0; i < newTxPerCoin.length; i++) { refreshCoins.push({ - amount: depositGroup.payCoinSelection.coinContributions[i], - coinPub: depositGroup.payCoinSelection.coinPubs[i], + amount: payCoinSelection.coinContributions[i], + coinPub: payCoinSelection.coinPubs[i], }); } let refreshRes: CreateRefreshGroupResult | undefined = undefined; @@ -740,9 +752,21 @@ async function processDepositGroupPendingTrack( wex: WalletExecutionContext, depositGroup: DepositGroupRecord, ): Promise<TaskRunResult> { + const statusPerCoin = depositGroup.statusPerCoin; + const payCoinSelection = depositGroup.payCoinSelection; + if (!statusPerCoin) { + throw Error( + "unable to refund deposit group without coin selection (status missing)", + ); + } + if (!payCoinSelection) { + throw Error( + "unable to refund deposit group without coin selection (selection missing)", + ); + } const { depositGroupId } = depositGroup; - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - const coinPub = depositGroup.payCoinSelection.coinPubs[i]; + for (let i = 0; i < statusPerCoin.length; i++) { + const coinPub = payCoinSelection.coinPubs[i]; // FIXME: Make the URL part of the coin selection? const exchangeBaseUrl = await wex.db.runReadWriteTx( ["coins"], @@ -761,7 +785,7 @@ async function processDepositGroupPendingTrack( } | undefined; - if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) { + if (statusPerCoin[i] !== DepositElementStatus.Wired) { const track = await trackDeposit( wex, depositGroup, @@ -826,6 +850,9 @@ async function processDepositGroupPendingTrack( if (!dg) { return; } + if (!dg.statusPerCoin) { + return; + } if (updatedTxStatus !== undefined) { dg.statusPerCoin[i] = updatedTxStatus; } @@ -858,9 +885,12 @@ async function processDepositGroupPendingTrack( if (!dg) { return undefined; } + if (!dg.statusPerCoin) { + return undefined; + } const oldTxState = computeDepositTransactionStatus(dg); - for (let i = 0; i < depositGroup.statusPerCoin.length; i++) { - if (depositGroup.statusPerCoin[i] !== DepositElementStatus.Wired) { + for (let i = 0; i < dg.statusPerCoin.length; i++) { + if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) { allWired = false; break; } @@ -924,6 +954,87 @@ async function processDepositGroupPendingDeposit( // Check for cancellation before expensive operations. cancellationToken?.throwIfCancelled(); + if (!depositGroup.payCoinSelection) { + logger.info("missing coin selection for deposit group, selecting now"); + // FIXME: Consider doing the coin selection inside the txn + const payCoinSel = await selectPayCoins(wex, { + restrictExchanges: { + auditors: [], + exchanges: contractData.allowedExchanges, + }, + restrictWireMethod: contractData.wireMethod, + contractTermsAmount: Amounts.parseOrThrow(contractData.amount), + depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee), + wireFeeAmortization: 1, // FIXME #8653 + prevPayCoins: [], + }); + + switch (payCoinSel.type) { + case "success": + logger.info("coin selection success"); + break; + case "failure": + logger.info("coin selection failure"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, + { + insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails, + }, + ); + case "prospective": + logger.info("coin selection prospective"); + throw Error("insufficient balance (waiting on pending refresh)"); + default: + assertUnreachable(payCoinSel); + } + + const transitionDone = await wex.db.runReadWriteTx( + [ + "depositGroups", + "coins", + "coinAvailability", + "refreshGroups", + "refreshSessions", + "denominations", + ], + async (tx) => { + const dg = await tx.depositGroups.get(depositGroupId); + if (!dg) { + return false; + } + if (dg.statusPerCoin) { + return false; + } + dg.payCoinSelection = { + coinContributions: payCoinSel.coinSel.coins.map( + (x) => x.contribution, + ), + coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub), + }; + dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + dg.statusPerCoin = payCoinSel.coinSel.coins.map( + () => DepositElementStatus.DepositPending, + ); + await tx.depositGroups.put(dg); + await spendCoins(wex, tx, { + allocationId: transactionId, + coinPubs: dg.payCoinSelection.coinPubs, + contributions: dg.payCoinSelection.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayDeposit, + }); + return true; + }, + ); + + if (transitionDone) { + return TaskRunResult.progress(); + } else { + return TaskRunResult.backoff(); + } + } + // FIXME: Cache these! const depositPermissions = await generateDepositPermissions( wex, @@ -990,6 +1101,9 @@ async function processDepositGroupPendingDeposit( if (!dg) { return; } + if (!dg.statusPerCoin) { + return; + } for (const batchIndex of batchIndexes) { const coinStatus = dg.statusPerCoin[batchIndex]; switch (coinStatus) { @@ -1360,8 +1474,11 @@ export async function createDepositGroup( prevPayCoins: [], }); + let coins: SelectedProspectiveCoin[] | undefined = undefined; + switch (payCoinSel.type) { case "success": + coins = payCoinSel.coinSel.coins; break; case "failure": throw TalerError.fromDetail( @@ -1371,17 +1488,13 @@ export async function createDepositGroup( }, ); case "prospective": - // FIXME: Here we need to create the deposit group without a full coin selection! - throw Error("insufficient balance (pending refresh)"); + coins = payCoinSel.result.prospectiveCoins; + break; default: assertUnreachable(payCoinSel); } - const totalDepositCost = await getTotalPaymentCost( - wex, - currency, - payCoinSel.coinSel.coins, - ); + const totalDepositCost = await getTotalPaymentCost(wex, currency, coins); let depositGroupId: string; if (req.transactionId) { @@ -1396,34 +1509,23 @@ export async function createDepositGroup( const infoPerExchange: Record<string, DepositInfoPerExchange> = {}; - await wex.db.runReadOnlyTx(["coins"], async (tx) => { - for (let i = 0; i < payCoinSel.coinSel.coins.length; i++) { - const coin = await tx.coins.get(payCoinSel.coinSel.coins[i].coinPub); - if (!coin) { - logger.error("coin not found anymore"); - continue; - } - let depPerExchange = infoPerExchange[coin.exchangeBaseUrl]; - if (!depPerExchange) { - infoPerExchange[coin.exchangeBaseUrl] = depPerExchange = { - amountEffective: Amounts.stringify( - Amounts.zeroOfAmount(totalDepositCost), - ), - }; - } - const contrib = payCoinSel.coinSel.coins[i].contribution; - depPerExchange.amountEffective = Amounts.stringify( - Amounts.add(depPerExchange.amountEffective, contrib).amount, - ); + for (let i = 0; i < coins.length; i++) { + let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl]; + if (!depPerExchange) { + infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = { + amountEffective: Amounts.stringify( + Amounts.zeroOfAmount(totalDepositCost), + ), + }; } - }); + const contrib = coins[i].contribution; + depPerExchange.amountEffective = Amounts.stringify( + Amounts.add(depPerExchange.amountEffective, contrib).amount, + ); + } const counterpartyEffectiveDepositAmount = - await getCounterpartyEffectiveDepositAmount( - wex, - p.targetType, - payCoinSel.coinSel.coins, - ); + await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins); const depositGroup: DepositGroupRecord = { contractTermsHash, @@ -1436,14 +1538,9 @@ export async function createDepositGroup( AbsoluteTime.toPreciseTimestamp(now), ), timestampFinished: undefined, - statusPerCoin: payCoinSel.coinSel.coins.map( - () => DepositElementStatus.DepositPending, - ), - payCoinSelection: { - coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution), - coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub), - }, - payCoinSelectionUid: encodeCrock(getRandomBytes(32)), + statusPerCoin: undefined, + payCoinSelection: undefined, + payCoinSelectionUid: undefined, merchantPriv: merchantPair.priv, merchantPub: merchantPair.pub, totalPayCost: Amounts.stringify(totalDepositCost), @@ -1461,6 +1558,17 @@ export async function createDepositGroup( infoPerExchange, }; + if (payCoinSel.type === "success") { + depositGroup.payCoinSelection = { + coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution), + coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub), + }; + depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32)); + depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map( + () => DepositElementStatus.DepositPending, + ); + } + const ctx = new DepositTransactionContext(wex, depositGroupId); const transactionId = ctx.transactionId; @@ -1476,14 +1584,16 @@ export async function createDepositGroup( "contractTerms", ], async (tx) => { - await spendCoins(wex, tx, { - allocationId: transactionId, - coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub), - contributions: payCoinSel.coinSel.coins.map((x) => - Amounts.parseOrThrow(x.contribution), - ), - refreshReason: RefreshReason.PayDeposit, - }); + if (depositGroup.payCoinSelection) { + await spendCoins(wex, tx, { + allocationId: transactionId, + coinPubs: depositGroup.payCoinSelection.coinPubs, + contributions: depositGroup.payCoinSelection.coinContributions.map( + (x) => Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayDeposit, + }); + } await tx.depositGroups.put(depositGroup); await tx.contractTerms.put({ contractTermsRaw: contractTerms, diff --git a/packages/taler-wallet-core/src/dev-experiments.ts b/packages/taler-wallet-core/src/dev-experiments.ts index 57810dbf4..7cf18e36c 100644 --- a/packages/taler-wallet-core/src/dev-experiments.ts +++ b/packages/taler-wallet-core/src/dev-experiments.ts @@ -67,52 +67,56 @@ export async function applyDevExperiment( throw Error("can't handle devmode URI unless devmode is active"); } - if (parsedUri.devExperimentId === "start-block-refresh") { - wex.ws.devExperimentState.blockRefreshes = true; - return; - } - - if (parsedUri.devExperimentId == "insert-pending-refresh") { - await wex.db.runReadWriteTx(["refreshGroups"], async (tx) => { - const refreshGroupId = encodeCrock(getRandomBytes(32)); - const newRg: RefreshGroupRecord = { - currency: "TESTKUDOS", - expectedOutputPerCoin: [], - inputPerCoin: [], - oldCoinPubs: [], - operationStatus: RefreshOperationStatus.Pending, - reason: RefreshReason.Manual, - refreshGroupId, - statusPerCoin: [], - timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), - timestampFinished: undefined, - originatingTransactionId: undefined, - infoPerExchange: {}, - }; - await tx.refreshGroups.put(newRg); - }); - return; - } - - if (parsedUri.devExperimentId == "insert-denom-loss") { - await wex.db.runReadWriteTx(["denomLossEvents"], async (tx) => { - const eventId = encodeCrock(getRandomBytes(32)); - const newRg: DenomLossEventRecord = { - amount: "TESTKUDOS:42", - currency: "TESTKUDOS", - exchangeBaseUrl: "https://exchange.test.taler.net/", - denomLossEventId: eventId, - denomPubHashes: [ - encodeCrock(getRandomBytes(64)), - encodeCrock(getRandomBytes(64)), - ], - eventType: DenomLossEventType.DenomExpired, - status: DenomLossStatus.Done, - timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), - }; - await tx.denomLossEvents.put(newRg); - }); - return; + switch (parsedUri.devExperimentId) { + case "start-block-refresh": { + wex.ws.devExperimentState.blockRefreshes = true; + return; + } + case "stop-block-refresh": { + wex.ws.devExperimentState.blockRefreshes = false; + return; + } + case "insert-pending-refresh": { + await wex.db.runReadWriteTx(["refreshGroups"], async (tx) => { + const refreshGroupId = encodeCrock(getRandomBytes(32)); + const newRg: RefreshGroupRecord = { + currency: "TESTKUDOS", + expectedOutputPerCoin: [], + inputPerCoin: [], + oldCoinPubs: [], + operationStatus: RefreshOperationStatus.Pending, + reason: RefreshReason.Manual, + refreshGroupId, + statusPerCoin: [], + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + timestampFinished: undefined, + originatingTransactionId: undefined, + infoPerExchange: {}, + }; + await tx.refreshGroups.put(newRg); + }); + return; + } + case "insert-denom-loss": { + await wex.db.runReadWriteTx(["denomLossEvents"], async (tx) => { + const eventId = encodeCrock(getRandomBytes(32)); + const newRg: DenomLossEventRecord = { + amount: "TESTKUDOS:42", + currency: "TESTKUDOS", + exchangeBaseUrl: "https://exchange.test.taler.net/", + denomLossEventId: eventId, + denomPubHashes: [ + encodeCrock(getRandomBytes(64)), + encodeCrock(getRandomBytes(64)), + ], + eventType: DenomLossEventType.DenomExpired, + status: DenomLossStatus.Done, + timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()), + }; + await tx.denomLossEvents.put(newRg); + }); + return; + } } throw Error(`dev-experiment id not understood ${parsedUri.devExperimentId}`); diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts index e404c0354..463aa97ba 100644 --- a/packages/taler-wallet-core/src/transactions.ts +++ b/packages/taler-wallet-core/src/transactions.ts @@ -894,10 +894,14 @@ function buildTransactionForDeposit( ort?: OperationRetryRecord, ): Transaction { let deposited = true; - for (const d of dg.statusPerCoin) { - if (d == DepositElementStatus.DepositPending) { - deposited = false; + if (dg.statusPerCoin) { + for (const d of dg.statusPerCoin) { + if (d == DepositElementStatus.DepositPending) { + deposited = false; + } } + } else { + deposited = false; } const trackingState: DepositTransactionTrackingState[] = []; @@ -911,6 +915,17 @@ function buildTransactionForDeposit( }); } + let wireTransferProgress = 0; + if (dg.statusPerCoin) { + wireTransferProgress = + (100 * + dg.statusPerCoin.reduce( + (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), + 0, + )) / + dg.statusPerCoin.length; + } + const txState = computeDepositTransactionStatus(dg); return { type: TransactionType.Deposit, @@ -927,13 +942,7 @@ function buildTransactionForDeposit( tag: TransactionType.Deposit, depositGroupId: dg.depositGroupId, }), - wireTransferProgress: - (100 * - dg.statusPerCoin.reduce( - (prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0), - 0, - )) / - dg.statusPerCoin.length, + wireTransferProgress, depositGroupId: dg.depositGroupId, trackingState, deposited, |