diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
10 files changed, 813 insertions, 1266 deletions
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index d4c822972..c77ce1a85 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -88,7 +88,6 @@ export async function exportBackup( backupProviders: x.backupProviders, tips: x.tips, recoupGroups: x.recoupGroups, - reserves: x.reserves, withdrawalGroups: x.withdrawalGroups, })) .runReadWrite(async (tx) => { @@ -128,29 +127,6 @@ export async function exportBackup( }); }); - await tx.reserves.iter().forEach((reserve) => { - const backupReserve: BackupReserve = { - initial_selected_denoms: reserve.initialDenomSel.selectedDenoms.map( - (x) => ({ - count: x.count, - denom_pub_hash: x.denomPubHash, - }), - ), - initial_withdrawal_group_id: reserve.initialWithdrawalGroupId, - instructed_amount: Amounts.stringify(reserve.instructedAmount), - reserve_priv: reserve.reservePriv, - timestamp_created: reserve.timestampCreated, - withdrawal_groups: - withdrawalGroupsByReserve[reserve.reservePub] ?? [], - // FIXME! - timestamp_last_activity: reserve.timestampCreated, - }; - const backupReserves = (backupReservesByExchange[ - reserve.exchangeBaseUrl - ] ??= []); - backupReserves.push(backupReserve); - }); - await tx.tips.iter().forEach((tip) => { backupTips.push({ exchange_base_url: tip.exchangeBaseUrl, diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index e099fae57..f26c42770 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -236,7 +236,6 @@ export async function importBackup( backupProviders: x.backupProviders, tips: x.tips, recoupGroups: x.recoupGroups, - reserves: x.reserves, withdrawalGroups: x.withdrawalGroups, tombstones: x.tombstones, depositGroups: x.depositGroups, @@ -427,94 +426,98 @@ export async function importBackup( } } - for (const backupReserve of backupExchangeDetails.reserves) { - const reservePub = - cryptoComp.reservePrivToPub[backupReserve.reserve_priv]; - const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub); - if (tombstoneSet.has(ts)) { - continue; - } - checkLogicInvariant(!!reservePub); - const existingReserve = await tx.reserves.get(reservePub); - const instructedAmount = Amounts.parseOrThrow( - backupReserve.instructed_amount, - ); - if (!existingReserve) { - let bankInfo: ReserveBankInfo | undefined; - if (backupReserve.bank_info) { - bankInfo = { - exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri, - statusUrl: backupReserve.bank_info.status_url, - confirmUrl: backupReserve.bank_info.confirm_url, - }; - } - await tx.reserves.put({ - currency: instructedAmount.currency, - instructedAmount, - exchangeBaseUrl: backupExchangeDetails.base_url, - reservePub, - reservePriv: backupReserve.reserve_priv, - bankInfo, - timestampCreated: backupReserve.timestamp_created, - timestampBankConfirmed: - backupReserve.bank_info?.timestamp_bank_confirmed, - timestampReserveInfoPosted: - backupReserve.bank_info?.timestamp_reserve_info_posted, - senderWire: backupReserve.sender_wire, - retryInfo: RetryInfo.reset(), - lastError: undefined, - initialWithdrawalGroupId: - backupReserve.initial_withdrawal_group_id, - initialWithdrawalStarted: - backupReserve.withdrawal_groups.length > 0, - // FIXME! - reserveStatus: ReserveRecordStatus.QueryingStatus, - initialDenomSel: await getDenomSelStateFromBackup( - tx, - backupExchangeDetails.base_url, - backupReserve.initial_selected_denoms, - ), - // FIXME! - operationStatus: OperationStatus.Pending, - }); - } - for (const backupWg of backupReserve.withdrawal_groups) { - const ts = makeEventId( - TombstoneTag.DeleteWithdrawalGroup, - backupWg.withdrawal_group_id, - ); - if (tombstoneSet.has(ts)) { - continue; - } - const existingWg = await tx.withdrawalGroups.get( - backupWg.withdrawal_group_id, - ); - if (!existingWg) { - await tx.withdrawalGroups.put({ - denomsSel: await getDenomSelStateFromBackup( - tx, - backupExchangeDetails.base_url, - backupWg.selected_denoms, - ), - exchangeBaseUrl: backupExchangeDetails.base_url, - lastError: undefined, - rawWithdrawalAmount: Amounts.parseOrThrow( - backupWg.raw_withdrawal_amount, - ), - reservePub, - retryInfo: RetryInfo.reset(), - secretSeed: backupWg.secret_seed, - timestampStart: backupWg.timestamp_created, - timestampFinish: backupWg.timestamp_finish, - withdrawalGroupId: backupWg.withdrawal_group_id, - denomSelUid: backupWg.selected_denoms_id, - operationStatus: backupWg.timestamp_finish - ? OperationStatus.Finished - : OperationStatus.Pending, - }); - } - } - } + + // FIXME: import reserves with new schema + + // for (const backupReserve of backupExchangeDetails.reserves) { + // const reservePub = + // cryptoComp.reservePrivToPub[backupReserve.reserve_priv]; + // const ts = makeEventId(TombstoneTag.DeleteReserve, reservePub); + // if (tombstoneSet.has(ts)) { + // continue; + // } + // checkLogicInvariant(!!reservePub); + // const existingReserve = await tx.reserves.get(reservePub); + // const instructedAmount = Amounts.parseOrThrow( + // backupReserve.instructed_amount, + // ); + // if (!existingReserve) { + // let bankInfo: ReserveBankInfo | undefined; + // if (backupReserve.bank_info) { + // bankInfo = { + // exchangePaytoUri: backupReserve.bank_info.exchange_payto_uri, + // statusUrl: backupReserve.bank_info.status_url, + // confirmUrl: backupReserve.bank_info.confirm_url, + // }; + // } + // await tx.reserves.put({ + // currency: instructedAmount.currency, + // instructedAmount, + // exchangeBaseUrl: backupExchangeDetails.base_url, + // reservePub, + // reservePriv: backupReserve.reserve_priv, + // bankInfo, + // timestampCreated: backupReserve.timestamp_created, + // timestampBankConfirmed: + // backupReserve.bank_info?.timestamp_bank_confirmed, + // timestampReserveInfoPosted: + // backupReserve.bank_info?.timestamp_reserve_info_posted, + // senderWire: backupReserve.sender_wire, + // retryInfo: RetryInfo.reset(), + // lastError: undefined, + // initialWithdrawalGroupId: + // backupReserve.initial_withdrawal_group_id, + // initialWithdrawalStarted: + // backupReserve.withdrawal_groups.length > 0, + // // FIXME! + // reserveStatus: ReserveRecordStatus.QueryingStatus, + // initialDenomSel: await getDenomSelStateFromBackup( + // tx, + // backupExchangeDetails.base_url, + // backupReserve.initial_selected_denoms, + // ), + // // FIXME! + // operationStatus: OperationStatus.Pending, + // }); + // } + // for (const backupWg of backupReserve.withdrawal_groups) { + // const ts = makeEventId( + // TombstoneTag.DeleteWithdrawalGroup, + // backupWg.withdrawal_group_id, + // ); + // if (tombstoneSet.has(ts)) { + // continue; + // } + // const existingWg = await tx.withdrawalGroups.get( + // backupWg.withdrawal_group_id, + // ); + // if (!existingWg) { + // await tx.withdrawalGroups.put({ + // denomsSel: await getDenomSelStateFromBackup( + // tx, + // backupExchangeDetails.base_url, + // backupWg.selected_denoms, + // ), + // exchangeBaseUrl: backupExchangeDetails.base_url, + // lastError: undefined, + // rawWithdrawalAmount: Amounts.parseOrThrow( + // backupWg.raw_withdrawal_amount, + // ), + // reservePub, + // retryInfo: RetryInfo.reset(), + // secretSeed: backupWg.secret_seed, + // timestampStart: backupWg.timestamp_created, + // timestampFinish: backupWg.timestamp_finish, + // withdrawalGroupId: backupWg.withdrawal_group_id, + // denomSelUid: backupWg.selected_denoms_id, + // operationStatus: backupWg.timestamp_finish + // ? OperationStatus.Finished + // : OperationStatus.Pending, + // }); + // } + // } + // } + } for (const backupProposal of backupBlob.proposals) { @@ -920,10 +923,6 @@ export async function importBackup( } else if (type === TombstoneTag.DeleteRefund) { // Nothing required, will just prevent display // in the transactions list - } else if (type === TombstoneTag.DeleteReserve) { - // FIXME: Once we also have account (=kyc) reserves, - // we need to check if the reserve is an account before deleting here - await tx.reserves.delete(rest[0]); } else if (type === TombstoneTag.DeleteTip) { await tx.tips.delete(rest[0]); } else if (type === TombstoneTag.DeleteWithdrawalGroup) { diff --git a/packages/taler-wallet-core/src/operations/balance.ts b/packages/taler-wallet-core/src/operations/balance.ts index c26eb0cfc..4590f5051 100644 --- a/packages/taler-wallet-core/src/operations/balance.ts +++ b/packages/taler-wallet-core/src/operations/balance.ts @@ -41,7 +41,6 @@ interface WalletBalance { export async function getBalancesInsideTransaction( ws: InternalWalletState, tx: GetReadOnlyAccess<{ - reserves: typeof WalletStoresV1.reserves; coins: typeof WalletStoresV1.coins; refreshGroups: typeof WalletStoresV1.refreshGroups; withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; @@ -65,17 +64,6 @@ export async function getBalancesInsideTransaction( return balanceStore[currency]; }; - // Initialize balance to zero, even if we didn't start withdrawing yet. - await tx.reserves.iter().forEach((r) => { - const b = initBalance(r.currency); - if (!r.initialWithdrawalStarted) { - b.pendingIncoming = Amounts.add( - b.pendingIncoming, - r.initialDenomSel.totalCoinValue, - ).amount; - } - }); - await tx.coins.iter().forEach((c) => { // Only count fresh coins, as dormant coins will // already be in a refresh session. @@ -154,7 +142,6 @@ export async function getBalances( .mktx((x) => ({ coins: x.coins, refreshGroups: x.refreshGroups, - reserves: x.reserves, purchases: x.purchases, withdrawalGroups: x.withdrawalGroups, })) diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/peer-to-peer.ts index 658cbe4f7..4d2f2bb5f 100644 --- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts +++ b/packages/taler-wallet-core/src/operations/peer-to-peer.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2019 GNUnet e.V. + (C) 2022 GNUnet e.V. 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 @@ -30,35 +30,35 @@ import { codecForAmountString, codecForAny, codecForExchangeGetContractResponse, + constructPayPushUri, ContractTermsUtil, decodeCrock, Duration, eddsaGetPublic, encodeCrock, ExchangePurseMergeRequest, + getRandomBytes, InitiatePeerPushPaymentRequest, InitiatePeerPushPaymentResponse, j2s, Logger, + parsePayPushUri, strcmp, TalerProtocolTimestamp, UnblindedSignature, WalletAccountMergeFlags, } from "@gnu-taler/taler-util"; -import { url } from "inspector"; import { CoinStatus, + MergeReserveInfo, OperationStatus, - ReserveRecord, ReserveRecordStatus, + WithdrawalGroupRecord, } from "../db.js"; -import { - checkSuccessResponseOrThrow, - readSuccessResponseJsonOrThrow, - throwUnexpectedRequestError, -} from "../util/http.js"; +import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { checkDbInvariant } from "../util/invariants.js"; +import { internalCreateWithdrawalGroup } from "./withdraw.js"; const logger = new Logger("operations/peer-to-peer.ts"); @@ -265,6 +265,10 @@ export async function initiatePeerToPeerPush( mergePriv: mergePair.priv, pursePub: pursePair.pub, exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + talerUri: constructPayPushUri({ + exchangeBaseUrl: coinSelRes.exchangeBaseUrl, + contractPriv: econtractResp.contractPriv, + }), }; } @@ -281,26 +285,19 @@ export async function checkPeerPushPayment( ws: InternalWalletState, req: CheckPeerPushPaymentRequest, ): Promise<CheckPeerPushPaymentResponse> { - const getPurseUrl = new URL( - `purses/${req.pursePub}/deposit`, - req.exchangeBaseUrl, - ); + // FIXME: Check if existing record exists! - const contractPub = encodeCrock( - eddsaGetPublic(decodeCrock(req.contractPriv)), - ); + const uri = parsePayPushUri(req.talerUri); - const purseHttpResp = await ws.http.get(getPurseUrl.href); + if (!uri) { + throw Error("got invalid taler://pay-push URI"); + } - const purseStatus = await readSuccessResponseJsonOrThrow( - purseHttpResp, - codecForExchangePurseStatus(), - ); + const exchangeBaseUrl = uri.exchangeBaseUrl; + const contractPriv = uri.contractPriv; + const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); - const getContractUrl = new URL( - `contracts/${contractPub}`, - req.exchangeBaseUrl, - ); + const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl); const contractHttpResp = await ws.http.get(getContractUrl.href); @@ -309,22 +306,36 @@ export async function checkPeerPushPayment( codecForExchangeGetContractResponse(), ); + const pursePub = contractResp.purse_pub; + const dec = await ws.cryptoApi.decryptContractForMerge({ ciphertext: contractResp.econtract, - contractPriv: req.contractPriv, - pursePub: req.pursePub, + contractPriv: contractPriv, + pursePub: pursePub, }); + const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl); + + const purseHttpResp = await ws.http.get(getPurseUrl.href); + + const purseStatus = await readSuccessResponseJsonOrThrow( + purseHttpResp, + codecForExchangePurseStatus(), + ); + + const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32)); + await ws.db .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming, })) .runReadWrite(async (tx) => { await tx.peerPushPaymentIncoming.add({ - contractPriv: req.contractPriv, - exchangeBaseUrl: req.exchangeBaseUrl, + peerPushPaymentIncomingId, + contractPriv: contractPriv, + exchangeBaseUrl: exchangeBaseUrl, mergePriv: dec.mergePriv, - pursePub: req.pursePub, + pursePub: pursePub, timestampAccepted: TalerProtocolTimestamp.now(), contractTerms: dec.contractTerms, }); @@ -333,6 +344,7 @@ export async function checkPeerPushPayment( return { amount: purseStatus.balance, contractTerms: dec.contractTerms, + peerPushPaymentIncomingId, }; } @@ -343,9 +355,9 @@ export function talerPaytoFromExchangeReserve( const url = new URL(exchangeBaseUrl); let proto: string; if (url.protocol === "http:") { - proto = "taler+http"; + proto = "taler-reserve-http"; } else if (url.protocol === "https:") { - proto = "taler"; + proto = "taler-reserve"; } else { throw Error(`unsupported exchange base URL protocol (${url.protocol})`); } @@ -365,69 +377,45 @@ export async function acceptPeerPushPayment( const peerInc = await ws.db .mktx((x) => ({ peerPushPaymentIncoming: x.peerPushPaymentIncoming })) .runReadOnly(async (tx) => { - return tx.peerPushPaymentIncoming.get([ - req.exchangeBaseUrl, - req.pursePub, - ]); + return tx.peerPushPaymentIncoming.get(req.peerPushPaymentIncomingId); }); if (!peerInc) { - throw Error("can't accept unknown incoming p2p push payment"); + throw Error( + `can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`, + ); } const amount = Amounts.parseOrThrow(peerInc.contractTerms.amount); - // We have to create the key pair outside of the transaction, + // We have to eagerly create the key pair outside of the transaction, // due to the async crypto API. const newReservePair = await ws.cryptoApi.createEddsaKeypair({}); - const reserve: ReserveRecord | undefined = await ws.db + const mergeReserveInfo: MergeReserveInfo = await ws.db .mktx((x) => ({ exchanges: x.exchanges, - reserves: x.reserves, + withdrawalGroups: x.withdrawalGroups, })) .runReadWrite(async (tx) => { - const ex = await tx.exchanges.get(req.exchangeBaseUrl); + const ex = await tx.exchanges.get(peerInc.exchangeBaseUrl); checkDbInvariant(!!ex); - if (ex.currentMergeReservePub) { - return await tx.reserves.get(ex.currentMergeReservePub); + if (ex.currentMergeReserveInfo) { + return ex.currentMergeReserveInfo; } - const rec: ReserveRecord = { - exchangeBaseUrl: req.exchangeBaseUrl, - // FIXME: field will be removed in the future, folded into withdrawal/p2p record. - reserveStatus: ReserveRecordStatus.Dormant, - timestampCreated: TalerProtocolTimestamp.now(), - instructedAmount: Amounts.getZero(amount.currency), - currency: amount.currency, - reservePub: newReservePair.pub, + await tx.exchanges.put(ex); + ex.currentMergeReserveInfo = { reservePriv: newReservePair.priv, - timestampBankConfirmed: undefined, - timestampReserveInfoPosted: undefined, - // FIXME! - initialDenomSel: undefined as any, - // FIXME! - initialWithdrawalGroupId: "", - initialWithdrawalStarted: false, - lastError: undefined, - operationStatus: OperationStatus.Pending, - retryInfo: undefined, - bankInfo: undefined, - restrictAge: undefined, - senderWire: undefined, + reservePub: newReservePair.pub, }; - await tx.reserves.put(rec); - return rec; + return ex.currentMergeReserveInfo; }); - if (!reserve) { - throw Error("can't create reserve"); - } - const mergeTimestamp = TalerProtocolTimestamp.now(); const reservePayto = talerPaytoFromExchangeReserve( - reserve.exchangeBaseUrl, - reserve.reservePub, + peerInc.exchangeBaseUrl, + mergeReserveInfo.reservePub, ); const sigRes = await ws.cryptoApi.signPurseMerge({ @@ -442,12 +430,12 @@ export async function acceptPeerPushPayment( purseFee: Amounts.stringify(Amounts.getZero(amount.currency)), pursePub: peerInc.pursePub, reservePayto, - reservePriv: reserve.reservePriv, + reservePriv: mergeReserveInfo.reservePriv, }); const mergePurseUrl = new URL( - `purses/${req.pursePub}/merge`, - req.exchangeBaseUrl, + `purses/${peerInc.pursePub}/merge`, + peerInc.exchangeBaseUrl, ); const mergeReq: ExchangePurseMergeRequest = { @@ -459,6 +447,17 @@ export async function acceptPeerPushPayment( const mergeHttpReq = await ws.http.postJson(mergePurseUrl.href, mergeReq); + logger.info(`merge request: ${j2s(mergeReq)}`); const res = await readSuccessResponseJsonOrThrow(mergeHttpReq, codecForAny()); - logger.info(`merge result: ${j2s(res)}`); + logger.info(`merge response: ${j2s(res)}`); + + await internalCreateWithdrawalGroup(ws, { + amount, + exchangeBaseUrl: peerInc.exchangeBaseUrl, + reserveStatus: ReserveRecordStatus.QueryingStatus, + reserveKeyPair: { + priv: mergeReserveInfo.reservePriv, + pub: mergeReserveInfo.reservePub, + }, + }); } diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index 0a262d3bb..ae93711f9 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -70,44 +70,6 @@ async function gatherExchangePending( }); } -async function gatherReservePending( - tx: GetReadOnlyAccess<{ reserves: typeof WalletStoresV1.reserves }>, - now: AbsoluteTime, - resp: PendingOperationsResponse, -): Promise<void> { - const reserves = await tx.reserves.indexes.byStatus.getAll( - OperationStatus.Pending, - ); - for (const reserve of reserves) { - const reserveType = reserve.bankInfo - ? ReserveType.TalerBankWithdraw - : ReserveType.Manual; - switch (reserve.reserveStatus) { - case ReserveRecordStatus.Dormant: - // nothing to report as pending - break; - case ReserveRecordStatus.WaitConfirmBank: - case ReserveRecordStatus.QueryingStatus: - case ReserveRecordStatus.RegisteringBank: { - resp.pendingOperations.push({ - type: PendingTaskType.Reserve, - givesLifeness: true, - timestampDue: reserve.retryInfo?.nextRetry ?? AbsoluteTime.now(), - stage: reserve.reserveStatus, - timestampCreated: reserve.timestampCreated, - reserveType, - reservePub: reserve.reservePub, - retryInfo: reserve.retryInfo, - }); - break; - } - default: - // FIXME: report problem! - break; - } - } -} - async function gatherRefreshPending( tx: GetReadOnlyAccess<{ refreshGroups: typeof WalletStoresV1.refreshGroups }>, now: AbsoluteTime, @@ -336,7 +298,6 @@ export async function getPendingOperations( backupProviders: x.backupProviders, exchanges: x.exchanges, exchangeDetails: x.exchangeDetails, - reserves: x.reserves, refreshGroups: x.refreshGroups, coins: x.coins, withdrawalGroups: x.withdrawalGroups, @@ -352,7 +313,6 @@ export async function getPendingOperations( pendingOperations: [], }; await gatherExchangePending(tx, now, resp); - await gatherReservePending(tx, now, resp); await gatherRefreshPending(tx, now, resp); await gatherWithdrawalPending(tx, now, resp); await gatherProposalPending(tx, now, resp); diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts index d36a10287..7c0f79daf 100644 --- a/packages/taler-wallet-core/src/operations/recoup.ts +++ b/packages/taler-wallet-core/src/operations/recoup.ts @@ -26,28 +26,35 @@ */ import { Amounts, - codecForRecoupConfirmation, encodeCrock, getRandomBytes, j2s, Logger, NotificationType, + codecForRecoupConfirmation, + encodeCrock, + getRandomBytes, + j2s, + Logger, + NotificationType, RefreshReason, TalerErrorDetail, - TalerProtocolTimestamp, URL + TalerProtocolTimestamp, + URL, } from "@gnu-taler/taler-util"; import { CoinRecord, CoinSourceType, - CoinStatus, OperationStatus, RecoupGroupRecord, + CoinStatus, + OperationStatus, + RecoupGroupRecord, RefreshCoinSource, - ReserveRecordStatus, WalletStoresV1, WithdrawCoinSource + ReserveRecordStatus, + WalletStoresV1, + WithdrawCoinSource, } from "../db.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { GetReadWriteAccess } from "../util/query.js"; -import { - RetryInfo -} from "../util/retries.js"; +import { RetryInfo } from "../util/retries.js"; import { guardOperationException } from "./common.js"; import { createRefreshGroup, processRefreshGroup } from "./refresh.js"; -import { getReserveRequestTimeout, processReserve } from "./reserves.js"; - +import { internalCreateWithdrawalGroup } from "./withdraw.js"; const logger = new Logger("operations/recoup.ts"); @@ -182,34 +189,24 @@ async function recoupWithdrawCoin( cs: WithdrawCoinSource, ): Promise<void> { const reservePub = cs.reservePub; - const d = await ws.db + const denomInfo = await ws.db .mktx((x) => ({ - reserves: x.reserves, denominations: x.denominations, })) .runReadOnly(async (tx) => { - const reserve = await tx.reserves.get(reservePub); - if (!reserve) { - return; - } const denomInfo = await ws.getDenomInfo( ws, tx, - reserve.exchangeBaseUrl, + coin.exchangeBaseUrl, coin.denomPubHash, ); - if (!denomInfo) { - return; - } - return { reserve, denomInfo }; + return denomInfo; }); - if (!d) { + if (!denomInfo) { // FIXME: We should at least emit some pending operation / warning for this? return; } - const { reserve, denomInfo } = d; - ws.notify({ type: NotificationType.RecoupStarted, }); @@ -224,9 +221,7 @@ async function recoupWithdrawCoin( }); const reqUrl = new URL(`/coins/${coin.coinPub}/recoup`, coin.exchangeBaseUrl); logger.trace(`requesting recoup via ${reqUrl.href}`); - const resp = await ws.http.postJson(reqUrl.href, recoupRequest, { - timeout: getReserveRequestTimeout(reserve), - }); + const resp = await ws.http.postJson(reqUrl.href, recoupRequest); const recoupConfirmation = await readSuccessResponseJsonOrThrow( resp, codecForRecoupConfirmation(), @@ -244,7 +239,6 @@ async function recoupWithdrawCoin( .mktx((x) => ({ coins: x.coins, denominations: x.denominations, - reserves: x.reserves, recoupGroups: x.recoupGroups, refreshGroups: x.refreshGroups, })) @@ -260,18 +254,12 @@ async function recoupWithdrawCoin( if (!updatedCoin) { return; } - const updatedReserve = await tx.reserves.get(reserve.reservePub); - if (!updatedReserve) { - return; - } updatedCoin.status = CoinStatus.Dormant; const currency = updatedCoin.currentAmount.currency; updatedCoin.currentAmount = Amounts.getZero(currency); - updatedReserve.reserveStatus = ReserveRecordStatus.QueryingStatus; - updatedReserve.retryInfo = RetryInfo.reset(); - updatedReserve.operationStatus = OperationStatus.Pending; await tx.coins.put(updatedCoin); - await tx.reserves.put(updatedReserve); + // FIXME: Actually withdraw here! + // await internalCreateWithdrawalGroup(ws, {...}); await putGroupAsFinished(ws, tx, recoupGroup, coinIdx); }); @@ -341,7 +329,6 @@ async function recoupRefreshCoin( .mktx((x) => ({ coins: x.coins, denominations: x.denominations, - reserves: x.reserves, recoupGroups: x.recoupGroups, refreshGroups: x.refreshGroups, })) @@ -446,12 +433,6 @@ async function processRecoupGroupImpl( reserveSet.add(coin.coinSource.reservePub); } } - - for (const r of reserveSet.values()) { - processReserve(ws, r, { forceNow: true }).catch((e) => { - logger.error(`processing reserve ${r} after recoup failed`); - }); - } } export async function createRecoupGroup( diff --git a/packages/taler-wallet-core/src/operations/reserves.ts b/packages/taler-wallet-core/src/operations/reserves.ts deleted file mode 100644 index b33f574f4..000000000 --- a/packages/taler-wallet-core/src/operations/reserves.ts +++ /dev/null @@ -1,843 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2019 GNUnet e.V. - - 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/> - */ - -import { - AbsoluteTime, - AcceptWithdrawalResponse, - addPaytoQueryParams, - Amounts, - canonicalizeBaseUrl, - codecForBankWithdrawalOperationPostResponse, - codecForReserveStatus, - codecForWithdrawOperationStatusResponse, - CreateReserveRequest, - CreateReserveResponse, - Duration, - durationMax, - durationMin, - encodeCrock, - ForcedDenomSel, - getRandomBytes, - j2s, - Logger, - NotificationType, - randomBytes, - TalerErrorCode, - TalerErrorDetail, - URL, -} from "@gnu-taler/taler-util"; -import { - DenomSelectionState, - OperationStatus, - ReserveBankInfo, - ReserveRecord, - ReserveRecordStatus, - WalletStoresV1, - WithdrawalGroupRecord, -} from "../db.js"; -import { TalerError } from "../errors.js"; -import { InternalWalletState } from "../internal-wallet-state.js"; -import { assertUnreachable } from "../util/assertUnreachable.js"; -import { - readSuccessResponseJsonOrErrorCode, - readSuccessResponseJsonOrThrow, - throwUnexpectedRequestError, -} from "../util/http.js"; -import { GetReadOnlyAccess } from "../util/query.js"; -import { RetryInfo } from "../util/retries.js"; -import { guardOperationException } from "./common.js"; -import { - getExchangeDetails, - getExchangePaytoUri, - getExchangeTrust, - updateExchangeFromUrl, -} from "./exchanges.js"; -import { - getBankWithdrawalInfo, - getCandidateWithdrawalDenoms, - processWithdrawGroup, - selectForcedWithdrawalDenominations, - selectWithdrawalDenominations, - updateWithdrawalDenoms, -} from "./withdraw.js"; - -const logger = new Logger("taler-wallet-core:reserves.ts"); - -/** - * Set up the reserve's retry timeout in preparation for - * processing the reserve. - */ -async function setupReserveRetry( - ws: InternalWalletState, - reservePub: string, - options: { - reset: boolean; - }, -): Promise<void> { - await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadWrite(async (tx) => { - const r = await tx.reserves.get(reservePub); - if (!r) { - return; - } - if (options.reset) { - r.retryInfo = RetryInfo.reset(); - } else { - r.retryInfo = RetryInfo.increment(r.retryInfo); - } - delete r.lastError; - await tx.reserves.put(r); - }); -} - -/** - * Report an error that happened while processing the reserve. - * - * Logs the error via a notification and by storing it in the database. - */ -async function reportReserveError( - ws: InternalWalletState, - reservePub: string, - err: TalerErrorDetail, -): Promise<void> { - await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadWrite(async (tx) => { - const r = await tx.reserves.get(reservePub); - if (!r) { - return; - } - if (!r.retryInfo) { - logger.error(`got reserve error for inactive reserve (no retryInfo)`); - return; - } - r.lastError = err; - await tx.reserves.put(r); - }); - ws.notify({ - type: NotificationType.ReserveOperationError, - error: err, - }); -} - -/** - * Create a reserve, but do not flag it as confirmed yet. - * - * Adds the corresponding exchange as a trusted exchange if it is neither - * audited nor trusted already. - */ -export async function createReserve( - ws: InternalWalletState, - req: CreateReserveRequest, -): Promise<CreateReserveResponse> { - const keypair = await ws.cryptoApi.createEddsaKeypair({}); - const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); - const canonExchange = canonicalizeBaseUrl(req.exchange); - - let reserveStatus; - if (req.bankWithdrawStatusUrl) { - reserveStatus = ReserveRecordStatus.RegisteringBank; - } else { - reserveStatus = ReserveRecordStatus.QueryingStatus; - } - - let bankInfo: ReserveBankInfo | undefined; - - if (req.bankWithdrawStatusUrl) { - if (!req.exchangePaytoUri) { - throw Error( - "Exchange payto URI must be specified for a bank-integrated withdrawal", - ); - } - bankInfo = { - statusUrl: req.bankWithdrawStatusUrl, - exchangePaytoUri: req.exchangePaytoUri, - }; - } - - const initialWithdrawalGroupId = encodeCrock(getRandomBytes(32)); - - await updateWithdrawalDenoms(ws, canonExchange); - const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange); - - let initialDenomSel: DenomSelectionState; - if (req.forcedDenomSel) { - logger.warn("using forced denom selection"); - initialDenomSel = selectForcedWithdrawalDenominations( - req.amount, - denoms, - req.forcedDenomSel, - ); - } else { - initialDenomSel = selectWithdrawalDenominations(req.amount, denoms); - } - - const reserveRecord: ReserveRecord = { - instructedAmount: req.amount, - initialWithdrawalGroupId, - initialDenomSel, - initialWithdrawalStarted: false, - timestampCreated: now, - exchangeBaseUrl: canonExchange, - reservePriv: keypair.priv, - reservePub: keypair.pub, - senderWire: req.senderWire, - timestampBankConfirmed: undefined, - timestampReserveInfoPosted: undefined, - bankInfo, - reserveStatus, - retryInfo: RetryInfo.reset(), - lastError: undefined, - currency: req.amount.currency, - operationStatus: OperationStatus.Pending, - restrictAge: req.restrictAge, - }; - - const exchangeInfo = await updateExchangeFromUrl(ws, req.exchange); - const exchangeDetails = exchangeInfo.exchangeDetails; - if (!exchangeDetails) { - logger.trace(exchangeDetails); - throw Error("exchange not updated"); - } - const { isAudited, isTrusted } = await getExchangeTrust( - ws, - exchangeInfo.exchange, - ); - - const resp = await ws.db - .mktx((x) => ({ - exchangeTrust: x.exchangeTrust, - reserves: x.reserves, - bankWithdrawUris: x.bankWithdrawUris, - })) - .runReadWrite(async (tx) => { - // Check if we have already created a reserve for that bankWithdrawStatusUrl - if (reserveRecord.bankInfo?.statusUrl) { - const bwi = await tx.bankWithdrawUris.get( - reserveRecord.bankInfo.statusUrl, - ); - if (bwi) { - const otherReserve = await tx.reserves.get(bwi.reservePub); - if (otherReserve) { - logger.trace( - "returning existing reserve for bankWithdrawStatusUri", - ); - return { - exchange: otherReserve.exchangeBaseUrl, - reservePub: otherReserve.reservePub, - }; - } - } - await tx.bankWithdrawUris.put({ - reservePub: reserveRecord.reservePub, - talerWithdrawUri: reserveRecord.bankInfo.statusUrl, - }); - } - if (!isAudited && !isTrusted) { - await tx.exchangeTrust.put({ - currency: reserveRecord.currency, - exchangeBaseUrl: reserveRecord.exchangeBaseUrl, - exchangeMasterPub: exchangeDetails.masterPublicKey, - uids: [encodeCrock(getRandomBytes(32))], - }); - } - await tx.reserves.put(reserveRecord); - const r: CreateReserveResponse = { - exchange: canonExchange, - reservePub: keypair.pub, - }; - return r; - }); - - if (reserveRecord.reservePub === resp.reservePub) { - // Only emit notification when a new reserve was created. - ws.notify({ - type: NotificationType.ReserveCreated, - reservePub: reserveRecord.reservePub, - }); - } - - // Asynchronously process the reserve, but return - // to the caller already. - processReserve(ws, resp.reservePub, { forceNow: true }).catch((e) => { - logger.error("Processing reserve (after createReserve) failed:", e); - }); - - return resp; -} - -/** - * Re-query the status of a reserve. - */ -export async function forceQueryReserve( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadWrite(async (tx) => { - const reserve = await tx.reserves.get(reservePub); - if (!reserve) { - return; - } - // Only force status query where it makes sense - switch (reserve.reserveStatus) { - case ReserveRecordStatus.Dormant: - reserve.reserveStatus = ReserveRecordStatus.QueryingStatus; - reserve.operationStatus = OperationStatus.Pending; - reserve.retryInfo = RetryInfo.reset(); - break; - default: - break; - } - await tx.reserves.put(reserve); - }); - await processReserve(ws, reservePub, { forceNow: true }); -} - -/** - * First fetch information required to withdraw from the reserve, - * then deplete the reserve, withdrawing coins until it is empty. - * - * The returned promise resolves once the reserve is set to the - * state "Dormant". - */ -export async function processReserve( - ws: InternalWalletState, - reservePub: string, - options: { - forceNow?: boolean; - } = {}, -): Promise<void> { - return ws.memoProcessReserve.memo(reservePub, async () => { - const onOpError = (err: TalerErrorDetail): Promise<void> => - reportReserveError(ws, reservePub, err); - await guardOperationException( - () => processReserveImpl(ws, reservePub, options), - onOpError, - ); - }); -} - -async function registerReserveWithBank( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - const reserve = await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadOnly(async (tx) => { - return await tx.reserves.get(reservePub); - }); - switch (reserve?.reserveStatus) { - case ReserveRecordStatus.WaitConfirmBank: - case ReserveRecordStatus.RegisteringBank: - break; - default: - return; - } - const bankInfo = reserve.bankInfo; - if (!bankInfo) { - return; - } - const bankStatusUrl = bankInfo.statusUrl; - const httpResp = await ws.http.postJson( - bankStatusUrl, - { - reserve_pub: reservePub, - selected_exchange: bankInfo.exchangePaytoUri, - }, - { - timeout: getReserveRequestTimeout(reserve), - }, - ); - await readSuccessResponseJsonOrThrow( - httpResp, - codecForBankWithdrawalOperationPostResponse(), - ); - await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadWrite(async (tx) => { - const r = await tx.reserves.get(reservePub); - if (!r) { - return; - } - switch (r.reserveStatus) { - case ReserveRecordStatus.RegisteringBank: - case ReserveRecordStatus.WaitConfirmBank: - break; - default: - return; - } - r.timestampReserveInfoPosted = AbsoluteTime.toTimestamp( - AbsoluteTime.now(), - ); - r.reserveStatus = ReserveRecordStatus.WaitConfirmBank; - r.operationStatus = OperationStatus.Pending; - if (!r.bankInfo) { - throw Error("invariant failed"); - } - r.retryInfo = RetryInfo.reset(); - await tx.reserves.put(r); - }); - ws.notify({ type: NotificationType.ReserveRegisteredWithBank }); - return processReserveBankStatus(ws, reservePub); -} - -export function getReserveRequestTimeout(r: ReserveRecord): Duration { - return durationMax( - { d_ms: 60000 }, - durationMin({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)), - ); -} - -async function processReserveBankStatus( - ws: InternalWalletState, - reservePub: string, -): Promise<void> { - const reserve = await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadOnly(async (tx) => { - return tx.reserves.get(reservePub); - }); - switch (reserve?.reserveStatus) { - case ReserveRecordStatus.WaitConfirmBank: - case ReserveRecordStatus.RegisteringBank: - break; - default: - return; - } - const bankStatusUrl = reserve.bankInfo?.statusUrl; - if (!bankStatusUrl) { - return; - } - - const statusResp = await ws.http.get(bankStatusUrl, { - timeout: getReserveRequestTimeout(reserve), - }); - const status = await readSuccessResponseJsonOrThrow( - statusResp, - codecForWithdrawOperationStatusResponse(), - ); - - if (status.aborted) { - logger.info("bank aborted the withdrawal"); - await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadWrite(async (tx) => { - const r = await tx.reserves.get(reservePub); - if (!r) { - return; - } - switch (r.reserveStatus) { - case ReserveRecordStatus.RegisteringBank: - case ReserveRecordStatus.WaitConfirmBank: - break; - default: - return; - } - const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); - r.timestampBankConfirmed = now; - r.reserveStatus = ReserveRecordStatus.BankAborted; - r.operationStatus = OperationStatus.Finished; - r.retryInfo = RetryInfo.reset(); - await tx.reserves.put(r); - }); - return; - } - - // Bank still needs to know our reserve info - if (!status.selection_done) { - await registerReserveWithBank(ws, reservePub); - return await processReserveBankStatus(ws, reservePub); - } - - // FIXME: Why do we do this?! - if (reserve.reserveStatus === ReserveRecordStatus.RegisteringBank) { - await registerReserveWithBank(ws, reservePub); - return await processReserveBankStatus(ws, reservePub); - } - - await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadWrite(async (tx) => { - const r = await tx.reserves.get(reservePub); - if (!r) { - return; - } - // Re-check reserve status within transaction - switch (r.reserveStatus) { - case ReserveRecordStatus.RegisteringBank: - case ReserveRecordStatus.WaitConfirmBank: - break; - default: - return; - } - if (status.transfer_done) { - const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); - r.timestampBankConfirmed = now; - r.reserveStatus = ReserveRecordStatus.QueryingStatus; - r.operationStatus = OperationStatus.Pending; - r.retryInfo = RetryInfo.reset(); - } else { - logger.info("Withdrawal operation not yet confirmed by bank"); - if (r.bankInfo) { - r.bankInfo.confirmUrl = status.confirm_transfer_url; - } - r.retryInfo = RetryInfo.increment(r.retryInfo); - } - await tx.reserves.put(r); - }); -} - -/** - * Update the information about a reserve that is stored in the wallet - * by querying the reserve's exchange. - * - * If the reserve have funds that are not allocated in a withdrawal group yet - * and are big enough to withdraw with available denominations, - * create a new withdrawal group for the remaining amount. - */ -async function updateReserve( - ws: InternalWalletState, - reservePub: string, -): Promise<{ ready: boolean }> { - const reserve = await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadOnly(async (tx) => { - return tx.reserves.get(reservePub); - }); - if (!reserve) { - throw Error("reserve not in db"); - } - - if (reserve.reserveStatus !== ReserveRecordStatus.QueryingStatus) { - return { ready: true }; - } - - const reserveUrl = new URL(`reserves/${reservePub}`, reserve.exchangeBaseUrl); - reserveUrl.searchParams.set("timeout_ms", "30000"); - - logger.info(`querying reserve status via ${reserveUrl}`); - - const resp = await ws.http.get(reserveUrl.href, { - timeout: getReserveRequestTimeout(reserve), - }); - - const result = await readSuccessResponseJsonOrErrorCode( - resp, - codecForReserveStatus(), - ); - - if (result.isError) { - if ( - resp.status === 404 && - result.talerErrorResponse.code === - TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN - ) { - ws.notify({ - type: NotificationType.ReserveNotYetFound, - reservePub, - }); - return { ready: false }; - } else { - throwUnexpectedRequestError(resp, result.talerErrorResponse); - } - } - - logger.trace(`got reserve status ${j2s(result.response)}`); - - const reserveInfo = result.response; - const reserveBalance = Amounts.parseOrThrow(reserveInfo.balance); - const currency = reserveBalance.currency; - - await updateWithdrawalDenoms(ws, reserve.exchangeBaseUrl); - const denoms = await getCandidateWithdrawalDenoms( - ws, - reserve.exchangeBaseUrl, - ); - - const newWithdrawalGroup = await ws.db - .mktx((x) => ({ - planchets: x.planchets, - withdrawalGroups: x.withdrawalGroups, - reserves: x.reserves, - denominations: x.denominations, - })) - .runReadWrite(async (tx) => { - const newReserve = await tx.reserves.get(reserve.reservePub); - if (!newReserve) { - return; - } - - let amountReservePlus = reserveBalance; - let amountReserveMinus = Amounts.getZero(currency); - - // Subtract amount allocated in unfinished withdrawal groups - // for this reserve from the available amount. - await tx.withdrawalGroups.indexes.byReservePub - .iter(reservePub) - .forEachAsync(async (wg) => { - if (wg.timestampFinish) { - return; - } - await tx.planchets.indexes.byGroup - .iter(wg.withdrawalGroupId) - .forEachAsync(async (pr) => { - if (pr.withdrawalDone) { - return; - } - const denomInfo = await ws.getDenomInfo( - ws, - tx, - wg.exchangeBaseUrl, - pr.denomPubHash, - ); - if (!denomInfo) { - logger.error(`no denom info found for ${pr.denomPubHash}`); - return; - } - amountReserveMinus = Amounts.add( - amountReserveMinus, - denomInfo.value, - denomInfo.feeWithdraw, - ).amount; - }); - }); - - const remainingAmount = Amounts.sub( - amountReservePlus, - amountReserveMinus, - ).amount; - - let withdrawalGroupId: string; - let denomSel: DenomSelectionState; - - if (!newReserve.initialWithdrawalStarted) { - withdrawalGroupId = newReserve.initialWithdrawalGroupId; - newReserve.initialWithdrawalStarted = true; - denomSel = newReserve.initialDenomSel; - } else { - withdrawalGroupId = encodeCrock(randomBytes(32)); - - denomSel = selectWithdrawalDenominations(remainingAmount, denoms); - - logger.trace( - `Remaining unclaimed amount in reseve is ${Amounts.stringify( - remainingAmount, - )} and can be withdrawn with ${denomSel.selectedDenoms.length} coins`, - ); - - if (denomSel.selectedDenoms.length === 0) { - newReserve.reserveStatus = ReserveRecordStatus.Dormant; - newReserve.operationStatus = OperationStatus.Finished; - delete newReserve.lastError; - delete newReserve.retryInfo; - await tx.reserves.put(newReserve); - return; - } - } - - const withdrawalRecord: WithdrawalGroupRecord = { - withdrawalGroupId: withdrawalGroupId, - exchangeBaseUrl: reserve.exchangeBaseUrl, - reservePub: reserve.reservePub, - rawWithdrawalAmount: remainingAmount, - timestampStart: AbsoluteTime.toTimestamp(AbsoluteTime.now()), - retryInfo: RetryInfo.reset(), - lastError: undefined, - denomsSel: denomSel, - secretSeed: encodeCrock(getRandomBytes(64)), - denomSelUid: encodeCrock(getRandomBytes(32)), - operationStatus: OperationStatus.Pending, - }; - - delete newReserve.lastError; - delete newReserve.retryInfo; - newReserve.reserveStatus = ReserveRecordStatus.Dormant; - newReserve.operationStatus = OperationStatus.Finished; - - await tx.reserves.put(newReserve); - await tx.withdrawalGroups.put(withdrawalRecord); - return withdrawalRecord; - }); - - if (newWithdrawalGroup) { - logger.trace("processing new withdraw group"); - ws.notify({ - type: NotificationType.WithdrawGroupCreated, - withdrawalGroupId: newWithdrawalGroup.withdrawalGroupId, - }); - await processWithdrawGroup(ws, newWithdrawalGroup.withdrawalGroupId); - } - - return { ready: true }; -} - -async function processReserveImpl( - ws: InternalWalletState, - reservePub: string, - options: { - forceNow?: boolean; - } = {}, -): Promise<void> { - const forceNow = options.forceNow ?? false; - await setupReserveRetry(ws, reservePub, { reset: forceNow }); - const reserve = await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadOnly(async (tx) => { - return tx.reserves.get(reservePub); - }); - if (!reserve) { - logger.error( - `not processing reserve: reserve ${reservePub} does not exist`, - ); - return; - } - logger.trace( - `Processing reserve ${reservePub} with status ${reserve.reserveStatus}`, - ); - switch (reserve.reserveStatus) { - case ReserveRecordStatus.RegisteringBank: - await processReserveBankStatus(ws, reservePub); - return await processReserveImpl(ws, reservePub, { forceNow: true }); - case ReserveRecordStatus.QueryingStatus: { - const res = await updateReserve(ws, reservePub); - if (res.ready) { - return await processReserveImpl(ws, reservePub, { forceNow: true }); - } - break; - } - case ReserveRecordStatus.Dormant: - // nothing to do - break; - case ReserveRecordStatus.WaitConfirmBank: - await processReserveBankStatus(ws, reservePub); - break; - case ReserveRecordStatus.BankAborted: - break; - default: - console.warn("unknown reserve record status:", reserve.reserveStatus); - assertUnreachable(reserve.reserveStatus); - break; - } -} - -/** - * Create a reserve for a bank-integrated withdrawal from - * a taler://withdraw URI. - */ -export async function createTalerWithdrawReserve( - ws: InternalWalletState, - talerWithdrawUri: string, - selectedExchange: string, - options: { - forcedDenomSel?: ForcedDenomSel; - restrictAge?: number; - } = {}, -): Promise<AcceptWithdrawalResponse> { - await updateExchangeFromUrl(ws, selectedExchange); - const withdrawInfo = await getBankWithdrawalInfo(ws.http, talerWithdrawUri); - const exchangePaytoUri = await getExchangePaytoUri( - ws, - selectedExchange, - withdrawInfo.wireTypes, - ); - const reserve = await createReserve(ws, { - amount: withdrawInfo.amount, - bankWithdrawStatusUrl: withdrawInfo.extractedStatusUrl, - exchange: selectedExchange, - senderWire: withdrawInfo.senderWire, - exchangePaytoUri: exchangePaytoUri, - restrictAge: options.restrictAge, - forcedDenomSel: options.forcedDenomSel, - }); - // We do this here, as the reserve should be registered before we return, - // so that we can redirect the user to the bank's status page. - await processReserveBankStatus(ws, reserve.reservePub); - const processedReserve = await ws.db - .mktx((x) => ({ - reserves: x.reserves, - })) - .runReadOnly(async (tx) => { - return tx.reserves.get(reserve.reservePub); - }); - if (processedReserve?.reserveStatus === ReserveRecordStatus.BankAborted) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - } - return { - reservePub: reserve.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - }; -} - -/** - * Get payto URIs that can be used to fund a reserve. - */ -export async function getFundingPaytoUris( - tx: GetReadOnlyAccess<{ - reserves: typeof WalletStoresV1.reserves; - exchanges: typeof WalletStoresV1.exchanges; - exchangeDetails: typeof WalletStoresV1.exchangeDetails; - }>, - reservePub: string, -): Promise<string[]> { - const r = await tx.reserves.get(reservePub); - if (!r) { - logger.error(`reserve ${reservePub} not found (DB corrupted?)`); - return []; - } - const exchangeDetails = await getExchangeDetails(tx, r.exchangeBaseUrl); - if (!exchangeDetails) { - logger.error(`exchange ${r.exchangeBaseUrl} not found (DB corrupted?)`); - return []; - } - const plainPaytoUris = - exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; - if (!plainPaytoUris) { - logger.error(`exchange ${r.exchangeBaseUrl} has no wire info`); - return []; - } - return plainPaytoUris.map((x) => - addPaytoQueryParams(x, { - amount: Amounts.stringify(r.instructedAmount), - message: `Taler Withdrawal ${r.reservePub}`, - }), - ); -} diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index d609011ca..bec8ec8f8 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -39,12 +39,12 @@ import { URL, PreparePayResultType, } from "@gnu-taler/taler-util"; -import { createTalerWithdrawReserve } from "./reserves.js"; import { InternalWalletState } from "../internal-wallet-state.js"; import { confirmPay, preparePayForUri } from "./pay.js"; import { getBalances } from "./balance.js"; import { applyRefund } from "./refund.js"; import { checkLogicInvariant } from "../util/invariants.js"; +import { acceptWithdrawalFromUri } from "./withdraw.js"; const logger = new Logger("operations/testing.ts"); @@ -104,14 +104,11 @@ export async function withdrawTestBalance( amount, ); - await createTalerWithdrawReserve( - ws, - wresp.taler_withdraw_uri, - exchangeBaseUrl, - { - forcedDenomSel: req.forcedDenomSel, - }, - ); + await acceptWithdrawalFromUri(ws, { + talerWithdrawUri: wresp.taler_withdraw_uri, + selectedExchange: exchangeBaseUrl, + forcedDenomSel: req.forcedDenomSel, + }); await confirmBankWithdrawalUri( ws.http, diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index ebc223b23..ae4ce6999 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -36,7 +36,6 @@ import { InternalWalletState } from "../internal-wallet-state.js"; import { AbortStatus, RefundState, - ReserveRecord, ReserveRecordStatus, WalletRefundItem, } from "../db.js"; @@ -44,9 +43,8 @@ import { processDepositGroup } from "./deposits.js"; import { getExchangeDetails } from "./exchanges.js"; import { processPurchasePay } from "./pay.js"; import { processRefreshGroup } from "./refresh.js"; -import { getFundingPaytoUris } from "./reserves.js"; import { processTip } from "./tip.js"; -import { processWithdrawGroup } from "./withdraw.js"; +import { processWithdrawalGroup } from "./withdraw.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); @@ -127,7 +125,6 @@ export async function getTransactions( proposals: x.proposals, purchases: x.purchases, refreshGroups: x.refreshGroups, - reserves: x.reserves, tips: x.tips, withdrawalGroups: x.withdrawalGroups, planchets: x.planchets, @@ -151,24 +148,13 @@ export async function getTransactions( if (shouldSkipSearch(transactionsRequest, [])) { return; } - - const r = await tx.reserves.get(wsr.reservePub); - if (!r) { - return; - } - let amountRaw: AmountJson | undefined = undefined; - if (wsr.withdrawalGroupId === r.initialWithdrawalGroupId) { - amountRaw = r.instructedAmount; - } else { - amountRaw = wsr.denomsSel.totalWithdrawCost; - } let withdrawalDetails: WithdrawalDetails; - if (r.bankInfo) { + if (wsr.bankInfo) { withdrawalDetails = { type: WithdrawalType.TalerBankIntegrationApi, - confirmed: r.timestampBankConfirmed ? true : false, + confirmed: wsr.bankInfo.timestampBankConfirmed ? true : false, reservePub: wsr.reservePub, - bankConfirmationUrl: r.bankInfo.confirmUrl, + bankConfirmationUrl: wsr.bankInfo.confirmUrl, }; } else { const exchangeDetails = await getExchangeDetails( @@ -191,7 +177,7 @@ export async function getTransactions( transactions.push({ type: TransactionType.Withdrawal, amountEffective: Amounts.stringify(wsr.denomsSel.totalCoinValue), - amountRaw: Amounts.stringify(amountRaw), + amountRaw: Amounts.stringify(wsr.rawWithdrawalAmount), withdrawalDetails, exchangeBaseUrl: wsr.exchangeBaseUrl, pending: !wsr.timestampFinish, @@ -205,56 +191,6 @@ export async function getTransactions( }); }); - // Report pending withdrawals based on reserves that - // were created, but where the actual withdrawal group has - // not started yet. - tx.reserves.iter().forEachAsync(async (r) => { - if (shouldSkipCurrency(transactionsRequest, r.currency)) { - return; - } - if (shouldSkipSearch(transactionsRequest, [])) { - return; - } - if (r.initialWithdrawalStarted) { - return; - } - if (r.reserveStatus === ReserveRecordStatus.BankAborted) { - return; - } - let withdrawalDetails: WithdrawalDetails; - if (r.bankInfo) { - withdrawalDetails = { - type: WithdrawalType.TalerBankIntegrationApi, - confirmed: false, - reservePub: r.reservePub, - bankConfirmationUrl: r.bankInfo.confirmUrl, - }; - } else { - withdrawalDetails = { - type: WithdrawalType.ManualTransfer, - reservePub: r.reservePub, - exchangePaytoUris: await getFundingPaytoUris(tx, r.reservePub), - }; - } - transactions.push({ - type: TransactionType.Withdrawal, - amountRaw: Amounts.stringify(r.instructedAmount), - amountEffective: Amounts.stringify( - r.initialDenomSel.totalCoinValue, - ), - exchangeBaseUrl: r.exchangeBaseUrl, - pending: true, - timestamp: r.timestampCreated, - withdrawalDetails: withdrawalDetails, - transactionId: makeEventId( - TransactionType.Withdrawal, - r.initialWithdrawalGroupId, - ), - frozen: false, - ...(r.lastError ? { error: r.lastError } : {}), - }); - }); - tx.depositGroups.iter().forEachAsync(async (dg) => { const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount); if (shouldSkipCurrency(transactionsRequest, amount.currency)) { @@ -499,7 +435,7 @@ export async function retryTransaction( } case TransactionType.Withdrawal: { const withdrawalGroupId = rest[0]; - await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true }); + await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }); break; } case TransactionType.Payment: { @@ -536,7 +472,6 @@ export async function deleteTransaction( await ws.db .mktx((x) => ({ withdrawalGroups: x.withdrawalGroups, - reserves: x.reserves, tombstones: x.tombstones, })) .runReadWrite(async (tx) => { @@ -550,17 +485,6 @@ export async function deleteTransaction( }); return; } - const reserveRecord: ReserveRecord | undefined = - await tx.reserves.indexes.byInitialWithdrawalGroupId.get( - withdrawalGroupId, - ); - if (reserveRecord && !reserveRecord.initialWithdrawalStarted) { - const reservePub = reserveRecord.reservePub; - await tx.reserves.delete(reservePub); - await tx.tombstones.put({ - id: TombstoneTag.DeleteReserve + ":" + reservePub, - }); - } }); } else if (type === TransactionType.Payment) { const proposalId = rest[0]; diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index ea9e22331..484b9b962 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -19,20 +19,29 @@ */ import { AbsoluteTime, + AcceptManualWithdrawalResult, + AcceptWithdrawalResponse, + addPaytoQueryParams, AmountJson, + AmountLike, Amounts, AmountString, BankWithdrawDetails, + canonicalizeBaseUrl, + codecForBankWithdrawalOperationPostResponse, + codecForReserveStatus, codecForTalerConfigResponse, codecForWithdrawBatchResponse, codecForWithdrawOperationStatusResponse, codecForWithdrawResponse, DenomKeyType, Duration, - durationFromSpec, + durationFromSpec, encodeCrock, ExchangeListItem, ExchangeWithdrawRequest, ForcedDenomSel, + getRandomBytes, + j2s, LibtoolVersion, Logger, NotificationType, @@ -45,8 +54,9 @@ import { VersionMatchResult, WithdrawBatchResponse, WithdrawResponse, - WithdrawUriInfoResponse, + WithdrawUriInfoResponse } from "@gnu-taler/taler-util"; +import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { CoinRecord, CoinSourceType, @@ -58,26 +68,42 @@ import { ExchangeRecord, OperationStatus, PlanchetRecord, - WithdrawalGroupRecord, + ReserveBankInfo, + ReserveRecordStatus, + WalletStoresV1, + WithdrawalGroupRecord } from "../db.js"; import { getErrorDetailFromException, makeErrorDetail, - TalerError, + TalerError } from "../errors.js"; import { InternalWalletState } from "../internal-wallet-state.js"; +import { assertUnreachable } from "../util/assertUnreachable.js"; import { walletCoreDebugFlags } from "../util/debugFlags.js"; import { HttpRequestLibrary, + readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, + throwUnexpectedRequestError } from "../util/http.js"; import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js"; +import { + DbAccess, + GetReadOnlyAccess +} from "../util/query.js"; import { RetryInfo } from "../util/retries.js"; import { WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, + WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js"; import { guardOperationException } from "./common.js"; +import { + getExchangeDetails, + getExchangePaytoUri, + getExchangeTrust, + updateExchangeFromUrl +} from "./exchanges.js"; /** * Logger for this file. @@ -215,7 +241,7 @@ export function selectWithdrawalDenominations( for (const d of denoms) { let count = 0; const cost = Amounts.add(d.value, d.feeWithdraw).amount; - for (; ;) { + for (;;) { if (Amounts.cmp(remaining, cost) < 0) { break; } @@ -410,47 +436,42 @@ async function processPlanchetGenerate( return; } let ci = 0; - let denomPubHash: string | undefined; + let maybeDenomPubHash: string | undefined; for (let di = 0; di < withdrawalGroup.denomsSel.selectedDenoms.length; di++) { const d = withdrawalGroup.denomsSel.selectedDenoms[di]; if (coinIdx >= ci && coinIdx < ci + d.count) { - denomPubHash = d.denomPubHash; + maybeDenomPubHash = d.denomPubHash; break; } ci += d.count; } - if (!denomPubHash) { + if (!maybeDenomPubHash) { throw Error("invariant violated"); } + const denomPubHash = maybeDenomPubHash; - const { denom, reserve } = await ws.db + const denom = await ws.db .mktx((x) => ({ - reserves: x.reserves, denominations: x.denominations, })) .runReadOnly(async (tx) => { - const denom = await tx.denominations.get([ + return ws.getDenomInfo( + ws, + tx, withdrawalGroup.exchangeBaseUrl, - denomPubHash!, - ]); - if (!denom) { - throw Error("invariant violated"); - } - const reserve = await tx.reserves.get(withdrawalGroup.reservePub); - if (!reserve) { - throw Error("invariant violated"); - } - return { denom, reserve }; + denomPubHash, + ); }); + checkDbInvariant(!!denom); const r = await ws.cryptoApi.createPlanchet({ denomPub: denom.denomPub, feeWithdraw: denom.feeWithdraw, - reservePriv: reserve.reservePriv, - reservePub: reserve.reservePub, + reservePriv: withdrawalGroup.reservePriv, + reservePub: withdrawalGroup.reservePub, value: denom.value, coinIndex: coinIdx, secretSeed: withdrawalGroup.secretSeed, - restrictAge: reserve.restrictAge, + restrictAge: withdrawalGroup.restrictAge, }); const newPlanchet: PlanchetRecord = { blindingKey: r.blindingKey, @@ -806,11 +827,13 @@ async function processPlanchetVerifyAndStoreCoin( const planchetCoinPub = planchet.coinPub; + // Check if this is the first time that the whole + // withdrawal succeeded. If so, mark the withdrawal + // group as finished. const firstSuccess = await ws.db .mktx((x) => ({ coins: x.coins, withdrawalGroups: x.withdrawalGroups, - reserves: x.reserves, planchets: x.planchets, })) .runReadWrite(async (tx) => { @@ -875,7 +898,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; @@ -960,7 +984,80 @@ async function reportWithdrawalError( ws.notify({ type: NotificationType.WithdrawOperationError, error: err }); } -export async function processWithdrawGroup( +/** + * Update the information about a reserve that is stored in the wallet + * by querying the reserve's exchange. + * + * If the reserve have funds that are not allocated in a withdrawal group yet + * and are big enough to withdraw with available denominations, + * create a new withdrawal group for the remaining amount. + */ +async function queryReserve( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise<{ ready: boolean }> { + const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { + withdrawalGroupId, + }); + checkDbInvariant(!!withdrawalGroup); + if (withdrawalGroup.reserveStatus !== ReserveRecordStatus.QueryingStatus) { + return { ready: true }; + } + const reservePub = withdrawalGroup.reservePub; + + const reserveUrl = new URL( + `reserves/${reservePub}`, + withdrawalGroup.exchangeBaseUrl, + ); + reserveUrl.searchParams.set("timeout_ms", "30000"); + + logger.info(`querying reserve status via ${reserveUrl}`); + + const resp = await ws.http.get(reserveUrl.href, { + timeout: getReserveRequestTimeout(withdrawalGroup), + }); + + const result = await readSuccessResponseJsonOrErrorCode( + resp, + codecForReserveStatus(), + ); + + if (result.isError) { + if ( + resp.status === 404 && + result.talerErrorResponse.code === + TalerErrorCode.EXCHANGE_RESERVES_STATUS_UNKNOWN + ) { + ws.notify({ + type: NotificationType.ReserveNotYetFound, + reservePub, + }); + return { ready: false }; + } else { + throwUnexpectedRequestError(resp, result.talerErrorResponse); + } + } + + logger.trace(`got reserve status ${j2s(result.response)}`); + + await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + })) + .runReadWrite(async (tx) => { + const wg = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!wg) { + logger.warn(`withdrawal group ${withdrawalGroupId} not found`); + return; + } + wg.reserveStatus = ReserveRecordStatus.Dormant; + await tx.withdrawalGroups.put(wg); + }); + + return { ready: true }; +} + +export async function processWithdrawalGroup( ws: InternalWalletState, withdrawalGroupId: string, options: { @@ -990,24 +1087,42 @@ async function processWithdrawGroupImpl( .runReadOnly(async (tx) => { return tx.withdrawalGroups.get(withdrawalGroupId); }); + if (!withdrawalGroup) { - // Withdrawal group doesn't exist yet, but reserve might exist - // (and reference the yet to be created withdrawal group) - const reservePub = await ws.db - .mktx((x) => ({ reserves: x.reserves })) - .runReadOnly(async (tx) => { - const r = await tx.reserves.indexes.byInitialWithdrawalGroupId.get( - withdrawalGroupId, - ); - return r?.reservePub; + throw Error(`withdrawal group ${withdrawalGroupId} not found`); + } + + switch (withdrawalGroup.reserveStatus) { + case ReserveRecordStatus.RegisteringBank: + await processReserveBankStatus(ws, withdrawalGroupId); + return await processWithdrawGroupImpl(ws, withdrawalGroupId, { + forceNow: true, }); - if (!reservePub) { - logger.warn( - "withdrawal group doesn't exist (and reserve doesn't exist either)", - ); + case ReserveRecordStatus.QueryingStatus: { + const res = await queryReserve(ws, withdrawalGroupId); + if (res.ready) { + return await processWithdrawGroupImpl(ws, withdrawalGroupId, { + forceNow: true, + }); + } return; } - return await ws.reserveOps.processReserve(ws, reservePub, { forceNow }); + case ReserveRecordStatus.WaitConfirmBank: + await processReserveBankStatus(ws, withdrawalGroupId); + return; + case ReserveRecordStatus.BankAborted: + // FIXME + return; + case ReserveRecordStatus.Dormant: + // We can try to withdraw, nothing needs to be done with the reserve. + break; + default: + logger.warn( + "unknown reserve record status:", + withdrawalGroup.reserveStatus, + ); + assertUnreachable(withdrawalGroup.reserveStatus); + break; } await ws.exchangeOps.updateExchangeFromUrl( @@ -1071,7 +1186,6 @@ async function processWithdrawGroupImpl( .mktx((x) => ({ coins: x.coins, withdrawalGroups: x.withdrawalGroups, - reserves: x.reserves, planchets: x.planchets, })) .runReadWrite(async (tx) => { @@ -1200,9 +1314,9 @@ export async function getExchangeWithdrawalInfo( !versionMatch.compatible && versionMatch.currentCmp === -1 ) { - console.warn( + logger.warn( `wallet's support for exchange protocol version ${WALLET_EXCHANGE_PROTOCOL_VERSION} might be outdated ` + - `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, + `(exchange has ${exchangeDetails.protocolVersion}), checking for updates`, ); } } @@ -1308,3 +1422,456 @@ export async function getWithdrawalDetailsForUri( possibleExchanges: exchanges, }; } + +export async function getFundingPaytoUrisTx( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise<string[]> { + return await ws.db + .mktx((x) => ({ + exchanges: x.exchanges, + exchangeDetails: x.exchangeDetails, + withdrawalGroups: x.withdrawalGroups, + })) + .runReadWrite((tx) => getFundingPaytoUris(tx, withdrawalGroupId)); +} + +/** + * Get payto URIs that can be used to fund a withdrawal operation. + */ +export async function getFundingPaytoUris( + tx: GetReadOnlyAccess<{ + withdrawalGroups: typeof WalletStoresV1.withdrawalGroups; + exchanges: typeof WalletStoresV1.exchanges; + exchangeDetails: typeof WalletStoresV1.exchangeDetails; + }>, + withdrawalGroupId: string, +): Promise<string[]> { + const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); + checkDbInvariant(!!withdrawalGroup); + const exchangeDetails = await getExchangeDetails( + tx, + withdrawalGroup.exchangeBaseUrl, + ); + if (!exchangeDetails) { + logger.error(`exchange ${withdrawalGroup.exchangeBaseUrl} not found`); + return []; + } + const plainPaytoUris = + exchangeDetails.wireInfo?.accounts.map((x) => x.payto_uri) ?? []; + if (!plainPaytoUris) { + logger.error( + `exchange ${withdrawalGroup.exchangeBaseUrl} has no wire info`, + ); + return []; + } + return plainPaytoUris.map((x) => + addPaytoQueryParams(x, { + amount: Amounts.stringify(withdrawalGroup.instructedAmount), + message: `Taler Withdrawal ${withdrawalGroup.reservePub}`, + }), + ); +} + +async function getWithdrawalGroupRecordTx( + db: DbAccess<typeof WalletStoresV1>, + req: { + withdrawalGroupId: string; + }, +): Promise<WithdrawalGroupRecord | undefined> { + return await db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + })) + .runReadOnly(async (tx) => { + return tx.withdrawalGroups.get(req.withdrawalGroupId); + }); +} + +export function getReserveRequestTimeout(r: WithdrawalGroupRecord): Duration { + return Duration.max( + { d_ms: 60000 }, + Duration.min({ d_ms: 5000 }, RetryInfo.getDuration(r.retryInfo)), + ); +} + +async function registerReserveWithBank( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise<void> { + const withdrawalGroup = await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + })) + .runReadOnly(async (tx) => { + return await tx.withdrawalGroups.get(withdrawalGroupId); + }); + switch (withdrawalGroup?.reserveStatus) { + case ReserveRecordStatus.WaitConfirmBank: + case ReserveRecordStatus.RegisteringBank: + break; + default: + return; + } + const bankInfo = withdrawalGroup.bankInfo; + if (!bankInfo) { + return; + } + const bankStatusUrl = bankInfo.statusUrl; + const httpResp = await ws.http.postJson( + bankStatusUrl, + { + reserve_pub: withdrawalGroup.reservePub, + selected_exchange: bankInfo.exchangePaytoUri, + }, + { + timeout: getReserveRequestTimeout(withdrawalGroup), + }, + ); + await readSuccessResponseJsonOrThrow( + httpResp, + codecForBankWithdrawalOperationPostResponse(), + ); + await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + })) + .runReadWrite(async (tx) => { + const r = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!r) { + return; + } + switch (r.reserveStatus) { + case ReserveRecordStatus.RegisteringBank: + case ReserveRecordStatus.WaitConfirmBank: + break; + default: + return; + } + if (!r.bankInfo) { + throw Error("invariant failed"); + } + r.bankInfo.timestampReserveInfoPosted = AbsoluteTime.toTimestamp( + AbsoluteTime.now(), + ); + r.reserveStatus = ReserveRecordStatus.WaitConfirmBank; + r.operationStatus = OperationStatus.Pending; + r.retryInfo = RetryInfo.reset(); + await tx.withdrawalGroups.put(r); + }); + ws.notify({ type: NotificationType.ReserveRegisteredWithBank }); + return processReserveBankStatus(ws, withdrawalGroupId); +} + +async function processReserveBankStatus( + ws: InternalWalletState, + withdrawalGroupId: string, +): Promise<void> { + const withdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { + withdrawalGroupId, + }); + switch (withdrawalGroup?.reserveStatus) { + case ReserveRecordStatus.WaitConfirmBank: + case ReserveRecordStatus.RegisteringBank: + break; + default: + return; + } + const bankStatusUrl = withdrawalGroup.bankInfo?.statusUrl; + if (!bankStatusUrl) { + return; + } + + const statusResp = await ws.http.get(bankStatusUrl, { + timeout: getReserveRequestTimeout(withdrawalGroup), + }); + const status = await readSuccessResponseJsonOrThrow( + statusResp, + codecForWithdrawOperationStatusResponse(), + ); + + if (status.aborted) { + logger.info("bank aborted the withdrawal"); + await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + })) + .runReadWrite(async (tx) => { + const r = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!r) { + return; + } + switch (r.reserveStatus) { + case ReserveRecordStatus.RegisteringBank: + case ReserveRecordStatus.WaitConfirmBank: + break; + default: + return; + } + if (!r.bankInfo) { + throw Error("invariant failed"); + } + const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); + r.bankInfo.timestampBankConfirmed = now; + r.reserveStatus = ReserveRecordStatus.BankAborted; + r.operationStatus = OperationStatus.Finished; + r.retryInfo = RetryInfo.reset(); + await tx.withdrawalGroups.put(r); + }); + return; + } + + // Bank still needs to know our reserve info + if (!status.selection_done) { + await registerReserveWithBank(ws, withdrawalGroupId); + return await processReserveBankStatus(ws, withdrawalGroupId); + } + + // FIXME: Why do we do this?! + if (withdrawalGroup.reserveStatus === ReserveRecordStatus.RegisteringBank) { + await registerReserveWithBank(ws, withdrawalGroupId); + return await processReserveBankStatus(ws, withdrawalGroupId); + } + + await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + })) + .runReadWrite(async (tx) => { + const r = await tx.withdrawalGroups.get(withdrawalGroupId); + if (!r) { + return; + } + // Re-check reserve status within transaction + switch (r.reserveStatus) { + case ReserveRecordStatus.RegisteringBank: + case ReserveRecordStatus.WaitConfirmBank: + break; + default: + return; + } + if (status.transfer_done) { + logger.info("withdrawal: transfer confirmed by bank."); + const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); + if (!r.bankInfo) { + throw Error("invariant failed"); + } + r.bankInfo.timestampBankConfirmed = now; + r.reserveStatus = ReserveRecordStatus.QueryingStatus; + r.operationStatus = OperationStatus.Pending; + r.retryInfo = RetryInfo.reset(); + } else { + logger.info("withdrawal: transfer not yet confirmed by bank"); + if (r.bankInfo) { + r.bankInfo.confirmUrl = status.confirm_transfer_url; + } + r.retryInfo = RetryInfo.increment(r.retryInfo); + } + await tx.withdrawalGroups.put(r); + }); +} + +export async function internalCreateWithdrawalGroup( + ws: InternalWalletState, + args: { + reserveStatus: ReserveRecordStatus; + amount: AmountJson; + bankInfo?: ReserveBankInfo; + exchangeBaseUrl: string; + forcedDenomSel?: ForcedDenomSel; + reserveKeyPair?: EddsaKeypair; + restrictAge?: number; + }, +): Promise<WithdrawalGroupRecord> { + const reserveKeyPair = + args.reserveKeyPair ?? (await ws.cryptoApi.createEddsaKeypair({})); + const now = AbsoluteTime.toTimestamp(AbsoluteTime.now()); + const secretSeed = encodeCrock(getRandomBytes(32)); + const canonExchange = canonicalizeBaseUrl(args.exchangeBaseUrl); + const withdrawalGroupId = encodeCrock(getRandomBytes(32)); + const amount = args.amount; + + await updateWithdrawalDenoms(ws, canonExchange); + const denoms = await getCandidateWithdrawalDenoms(ws, canonExchange); + + let initialDenomSel: DenomSelectionState; + const denomSelUid = encodeCrock(getRandomBytes(16)); + if (args.forcedDenomSel) { + logger.warn("using forced denom selection"); + initialDenomSel = selectForcedWithdrawalDenominations( + amount, + denoms, + args.forcedDenomSel, + ); + } else { + initialDenomSel = selectWithdrawalDenominations(amount, denoms); + } + + const withdrawalGroup: WithdrawalGroupRecord = { + denomSelUid, + denomsSel: initialDenomSel, + exchangeBaseUrl: canonExchange, + instructedAmount: amount, + timestampStart: now, + lastError: undefined, + operationStatus: OperationStatus.Pending, + rawWithdrawalAmount: initialDenomSel.totalWithdrawCost, + secretSeed, + reservePriv: reserveKeyPair.priv, + reservePub: reserveKeyPair.pub, + reserveStatus: args.reserveStatus, + retryInfo: RetryInfo.reset(), + withdrawalGroupId, + bankInfo: args.bankInfo, + restrictAge: args.restrictAge, + senderWire: undefined, + timestampFinish: undefined, + }; + + const exchangeInfo = await updateExchangeFromUrl(ws, canonExchange); + const exchangeDetails = exchangeInfo.exchangeDetails; + if (!exchangeDetails) { + logger.trace(exchangeDetails); + throw Error("exchange not updated"); + } + const { isAudited, isTrusted } = await getExchangeTrust( + ws, + exchangeInfo.exchange, + ); + + await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + exchanges: x.exchanges, + exchangeDetails: x.exchangeDetails, + exchangeTrust: x.exchangeTrust, + })) + .runReadWrite(async (tx) => { + await tx.withdrawalGroups.add(withdrawalGroup); + + if (!isAudited && !isTrusted) { + await tx.exchangeTrust.put({ + currency: amount.currency, + exchangeBaseUrl: canonExchange, + exchangeMasterPub: exchangeDetails.masterPublicKey, + uids: [encodeCrock(getRandomBytes(32))], + }); + } + }); + + return withdrawalGroup; +} + +export async function acceptWithdrawalFromUri( + ws: InternalWalletState, + req: { + talerWithdrawUri: string; + selectedExchange: string; + forcedDenomSel?: ForcedDenomSel; + restrictAge?: number; + }, +): Promise<AcceptWithdrawalResponse> { + await updateExchangeFromUrl(ws, req.selectedExchange); + const withdrawInfo = await getBankWithdrawalInfo( + ws.http, + req.talerWithdrawUri, + ); + const exchangePaytoUri = await getExchangePaytoUri( + ws, + req.selectedExchange, + withdrawInfo.wireTypes, + ); + + const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { + amount: withdrawInfo.amount, + exchangeBaseUrl: req.selectedExchange, + forcedDenomSel: req.forcedDenomSel, + reserveStatus: ReserveRecordStatus.RegisteringBank, + bankInfo: { + exchangePaytoUri, + statusUrl: withdrawInfo.extractedStatusUrl, + confirmUrl: withdrawInfo.confirmTransferUrl, + }, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + // We do this here, as the reserve should be registered before we return, + // so that we can redirect the user to the bank's status page. + await processReserveBankStatus(ws, withdrawalGroupId); + const processedWithdrawalGroup = await getWithdrawalGroupRecordTx(ws.db, { + withdrawalGroupId, + }); + if ( + processedWithdrawalGroup?.reserveStatus === ReserveRecordStatus.BankAborted + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } + + // Start withdrawal in the background. + await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch( + (err) => { + logger.error("Processing withdrawal (after creation) failed:", err); + }, + ); + + return { + reservePub: withdrawalGroup.reservePub, + confirmTransferUrl: withdrawInfo.confirmTransferUrl, + }; +} + +/** + * Create a manual withdrawal operation. + * + * Adds the corresponding exchange as a trusted exchange if it is neither + * audited nor trusted already. + * + * Asynchronously starts the withdrawal. + */ +export async function createManualWithdrawal( + ws: InternalWalletState, + req: { + exchangeBaseUrl: string; + amount: AmountLike; + restrictAge?: number; + forcedDenomSel?: ForcedDenomSel; + }, +): Promise<AcceptManualWithdrawalResult> { + const withdrawalGroup = await internalCreateWithdrawalGroup(ws, { + amount: Amounts.jsonifyAmount(req.amount), + exchangeBaseUrl: req.exchangeBaseUrl, + bankInfo: undefined, + forcedDenomSel: req.forcedDenomSel, + restrictAge: req.restrictAge, + reserveStatus: ReserveRecordStatus.QueryingStatus, + }); + + const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; + + const exchangePaytoUris = await ws.db + .mktx((x) => ({ + withdrawalGroups: x.withdrawalGroups, + exchanges: x.exchanges, + exchangeDetails: x.exchangeDetails, + exchangeTrust: x.exchangeTrust, + })) + .runReadWrite(async (tx) => { + return await getFundingPaytoUris(tx, withdrawalGroup.withdrawalGroupId); + }); + + // Start withdrawal in the background. + await processWithdrawalGroup(ws, withdrawalGroupId, { forceNow: true }).catch( + (err) => { + logger.error("Processing withdrawal (after creation) failed:", err); + }, + ); + + return { + reservePub: withdrawalGroup.reservePub, + exchangePaytoUris: exchangePaytoUris, + }; +} |