diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
3 files changed, 301 insertions, 27 deletions
diff --git a/packages/taler-wallet-core/src/operations/attention.ts b/packages/taler-wallet-core/src/operations/attention.ts new file mode 100644 index 000000000..95db7bde0 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/attention.ts @@ -0,0 +1,145 @@ +/* + 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/> + */ + +/** + * Imports. + */ +import { + AbsoluteTime, + AttentionInfo, + Logger, + TalerProtocolTimestamp, + UserAttentionByIdRequest, + UserAttentionPriority, + UserAttentionsCountResponse, + UserAttentionsRequest, + UserAttentionsResponse, + UserAttentionUnreadList, +} from "@gnu-taler/taler-util"; +import { InternalWalletState } from "../internal-wallet-state.js"; + +const logger = new Logger("operations/attention.ts"); + +export async function getUserAttentionsUnreadCount( + ws: InternalWalletState, + req: UserAttentionsRequest, +): Promise<UserAttentionsCountResponse> { + const total = await ws.db + .mktx((x) => [x.userAttention]) + .runReadOnly(async (tx) => { + let count = 0; + await tx.userAttention.iter().forEach((x) => { + if ( + req.priority !== undefined && + UserAttentionPriority[x.info.type] !== req.priority + ) + return; + if (x.read !== undefined) return; + count++; + }); + + return count; + }); + + return { total }; +} + +export async function getUserAttentions( + ws: InternalWalletState, + req: UserAttentionsRequest, +): Promise<UserAttentionsResponse> { + return await ws.db + .mktx((x) => [x.userAttention]) + .runReadOnly(async (tx) => { + const pending: UserAttentionUnreadList = []; + await tx.userAttention.iter().forEach((x) => { + if ( + req.priority !== undefined && + UserAttentionPriority[x.info.type] !== req.priority + ) + return; + pending.push({ + info: x.info, + when: { + t_ms: x.createdMs, + }, + read: x.read !== undefined, + }); + }); + + return { pending }; + }); +} + +export async function markAttentionRequestAsRead( + ws: InternalWalletState, + req: UserAttentionByIdRequest, +): Promise<void> { + await ws.db + .mktx((x) => [x.userAttention]) + .runReadWrite(async (tx) => { + const ua = await tx.userAttention.get([req.entityId, req.type]); + if (!ua) throw Error("attention request not found"); + tx.userAttention.put({ + ...ua, + read: TalerProtocolTimestamp.now(), + }); + }); +} + +/** + * the wallet need the user attention to complete a task + * internal API + * + * @param ws + * @param info + */ +export async function addAttentionRequest( + ws: InternalWalletState, + info: AttentionInfo, + entityId: string, +): Promise<void> { + await ws.db + .mktx((x) => [x.userAttention]) + .runReadWrite(async (tx) => { + await tx.userAttention.put({ + info, + entityId, + createdMs: AbsoluteTime.now().t_ms as number, + read: undefined, + }); + }); +} + +/** + * user completed the task, attention request is not needed + * internal API + * + * @param ws + * @param created + */ +export async function removeAttentionRequest( + ws: InternalWalletState, + req: UserAttentionByIdRequest, +): Promise<void> { + await ws.db + .mktx((x) => [x.userAttention]) + .runReadWrite(async (tx) => { + const ua = await tx.userAttention.get([req.entityId, req.type]); + if (!ua) throw Error("attention request not found"); + await tx.userAttention.delete([req.entityId, req.type]); + }); +} diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index aed37b865..eef838b0c 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -27,6 +27,7 @@ import { AbsoluteTime, AmountString, + AttentionType, BackupRecovery, buildCodecForObject, buildCodecForUnion, @@ -57,13 +58,17 @@ import { kdf, Logger, notEmpty, + PaymentStatus, + PreparePayResult, PreparePayResultType, RecoveryLoadRequest, RecoveryMergeStrategy, + ReserveTransactionType, rsaBlind, secretbox, secretbox_open, stringToBytes, + TalerErrorCode, TalerErrorDetail, TalerProtocolTimestamp, URL, @@ -80,6 +85,7 @@ import { ConfigRecordKey, WalletBackupConfState, } from "../../db.js"; +import { TalerError } from "../../errors.js"; import { InternalWalletState } from "../../internal-wallet-state.js"; import { assertUnreachable } from "../../util/assertUnreachable.js"; import { @@ -96,6 +102,7 @@ import { RetryTags, scheduleRetryInTx, } from "../../util/retries.js"; +import { addAttentionRequest, removeAttentionRequest } from "../attention.js"; import { checkPaymentByProposalId, confirmPay, @@ -198,6 +205,7 @@ async function computeBackupCryptoData( ); } for (const purch of backupContent.purchases) { + if (!purch.contract_terms_raw) continue; const { h: contractTermsHash } = await cryptoApi.hashString({ str: canonicalJson(purch.contract_terms_raw), }); @@ -251,7 +259,7 @@ function getNextBackupTimestamp(): TalerProtocolTimestamp { async function runBackupCycleForProvider( ws: InternalWalletState, args: BackupForProviderArgs, -): Promise<OperationAttemptResult<unknown, { talerUri: string }>> { +): Promise<OperationAttemptResult<unknown, { talerUri?: string }>> { const provider = await ws.db .mktx((x) => [x.backupProviders]) .runReadOnly(async (tx) => { @@ -292,6 +300,10 @@ async function runBackupCycleForProvider( provider.baseUrl, ); + if (provider.shouldRetryFreshProposal) { + accountBackupUrl.searchParams.set("fresh", "yes"); + } + const resp = await ws.http.fetch(accountBackupUrl.href, { method: "POST", body: encBackup, @@ -324,6 +336,12 @@ async function runBackupCycleForProvider( }; await tx.backupProviders.put(prov); }); + + removeAttentionRequest(ws, { + entityId: provider.baseUrl, + type: AttentionType.BackupUnpaid, + }); + return { type: OperationAttemptResultType.Finished, result: undefined, @@ -340,8 +358,51 @@ async function runBackupCycleForProvider( //We can't delay downloading the proposal since we need the id //FIXME: check download errors + let res: PreparePayResult | undefined = undefined; + try { + res = await preparePayForUri(ws, talerUri); + } catch (e) { + const error = TalerError.fromException(e); + if (!error.hasErrorCode(TalerErrorCode.WALLET_ORDER_ALREADY_CLAIMED)) { + throw error; + } + } + + if ( + res === undefined || + res.status === PreparePayResultType.AlreadyConfirmed + ) { + //claimed + + await ws.db + .mktx((x) => [x.backupProviders, x.operationRetries]) + .runReadWrite(async (tx) => { + const prov = await tx.backupProviders.get(provider.baseUrl); + if (!prov) { + logger.warn("backup provider not found anymore"); + return; + } + const opId = RetryTags.forBackup(prov); + await scheduleRetryInTx(ws, tx, opId); + prov.shouldRetryFreshProposal = true; + prov.state = { + tag: BackupProviderStateTag.Retrying, + }; + await tx.backupProviders.put(prov); + }); - const res = await preparePayForUri(ws, talerUri); + return { + type: OperationAttemptResultType.Pending, + result: { + talerUri, + }, + }; + } + const result = res; + + if (result.status === PreparePayResultType.Lost) { + throw Error("invalid state, could not get proposal for backup"); + } await ws.db .mktx((x) => [x.backupProviders, x.operationRetries]) @@ -353,13 +414,24 @@ async function runBackupCycleForProvider( } const opId = RetryTags.forBackup(prov); await scheduleRetryInTx(ws, tx, opId); - prov.currentPaymentProposalId = res.proposalId; + prov.currentPaymentProposalId = result.proposalId; + prov.shouldRetryFreshProposal = false; prov.state = { tag: BackupProviderStateTag.Retrying, }; await tx.backupProviders.put(prov); }); + addAttentionRequest( + ws, + { + type: AttentionType.BackupUnpaid, + provider_base_url: provider.baseUrl, + talerUri, + }, + provider.baseUrl, + ); + return { type: OperationAttemptResultType.Pending, result: { @@ -384,6 +456,12 @@ async function runBackupCycleForProvider( }; await tx.backupProviders.put(prov); }); + + removeAttentionRequest(ws, { + entityId: provider.baseUrl, + type: AttentionType.BackupUnpaid, + }); + return { type: OperationAttemptResultType.Finished, result: undefined, @@ -564,7 +642,7 @@ interface AddBackupProviderOk { } interface AddBackupProviderPaymentRequired { status: "payment-required"; - talerUri: string; + talerUri?: string; } interface AddBackupProviderError { status: "error"; @@ -580,7 +658,7 @@ export const codecForAddBackupProviderPaymenrRequired = (): Codec<AddBackupProviderPaymentRequired> => buildCodecForObject<AddBackupProviderPaymentRequired>() .property("status", codecForConstString("payment-required")) - .property("talerUri", codecForString()) + .property("talerUri", codecOptional(codecForString())) .build("AddBackupProviderPaymentRequired"); export const codecForAddBackupProviderError = @@ -655,6 +733,7 @@ export async function addBackupProvider( storageLimitInMegabytes: terms.storage_limit_in_megabytes, supportedProtocolVersion: terms.version, }, + shouldRetryFreshProposal: false, paymentProposalIds: [], baseUrl: canonUrl, uids: [encodeCrock(getRandomBytes(32))], @@ -779,10 +858,12 @@ export interface ProviderPaymentUnpaid { export interface ProviderPaymentInsufficientBalance { type: ProviderPaymentType.InsufficientBalance; + amount: AmountString; } export interface ProviderPaymentPending { type: ProviderPaymentType.Pending; + talerUri?: string; } export interface ProviderPaymentPaid { @@ -810,32 +891,40 @@ async function getProviderPaymentInfo( ws, provider.currentPaymentProposalId, ); - if (status.status === PreparePayResultType.InsufficientBalance) { - return { - type: ProviderPaymentType.InsufficientBalance, - }; - } - if (status.status === PreparePayResultType.PaymentPossible) { - return { - type: ProviderPaymentType.Pending, - }; - } - if (status.status === PreparePayResultType.AlreadyConfirmed) { - if (status.paid) { + + switch (status.status) { + case PreparePayResultType.InsufficientBalance: return { - type: ProviderPaymentType.Paid, - paidUntil: AbsoluteTime.addDuration( - AbsoluteTime.fromTimestamp(status.contractTerms.timestamp), - durationFromSpec({ years: 1 }), - ), + type: ProviderPaymentType.InsufficientBalance, + amount: status.amountRaw, }; - } else { + case PreparePayResultType.PaymentPossible: return { type: ProviderPaymentType.Pending, + talerUri: status.talerUri, }; - } + case PreparePayResultType.Lost: + return { + type: ProviderPaymentType.Unpaid, + }; + case PreparePayResultType.AlreadyConfirmed: + if (status.paid) { + return { + type: ProviderPaymentType.Paid, + paidUntil: AbsoluteTime.addDuration( + AbsoluteTime.fromTimestamp(status.contractTerms.timestamp), + durationFromSpec({ years: 1 }), //FIXME: take this from the contract term + ), + }; + } else { + return { + type: ProviderPaymentType.Pending, + talerUri: status.talerUri, + }; + } + default: + assertUnreachable(status); } - throw Error("not reached"); } /** @@ -936,6 +1025,7 @@ async function backupRecoveryTheirs( baseUrl: prov.url, name: prov.name, paymentProposalIds: [], + shouldRetryFreshProposal: false, state: { tag: BackupProviderStateTag.Ready, nextBackupTimestamp: TalerProtocolTimestamp.now(), diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 6246951ad..d3d0a12bd 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -72,6 +72,7 @@ import { TalerProtocolTimestamp, TransactionType, URL, + constructPayUri, } from "@gnu-taler/taler-util"; import { EddsaKeypair } from "../crypto/cryptoImplementation.js"; import { @@ -1290,7 +1291,10 @@ export async function checkPaymentByProposalId( return tx.purchases.get(proposalId); }); if (!proposal) { - throw Error(`could not get proposal ${proposalId}`); + // throw Error(`could not get proposal ${proposalId}`); + return { + status: PreparePayResultType.Lost, + }; } if (proposal.purchaseStatus === PurchaseStatus.RepurchaseDetected) { const existingProposalId = proposal.repurchaseProposalId; @@ -1316,6 +1320,14 @@ export async function checkPaymentByProposalId( proposalId = proposal.proposalId; + const talerUri = constructPayUri( + proposal.merchantBaseUrl, + proposal.orderId, + proposal.lastSessionId ?? proposal.downloadSessionId ?? "", + proposal.claimToken, + proposal.noncePriv, + ); + // First check if we already paid for it. const purchase = await ws.db .mktx((x) => [x.purchases]) @@ -1345,6 +1357,7 @@ export async function checkPaymentByProposalId( proposalId: proposal.proposalId, noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), + talerUri, }; } @@ -1360,6 +1373,7 @@ export async function checkPaymentByProposalId( amountEffective: Amounts.stringify(totalCost), amountRaw: Amounts.stringify(res.paymentAmount), contractTermsHash: d.contractData.contractTermsHash, + talerUri, }; } @@ -1396,6 +1410,7 @@ export async function checkPaymentByProposalId( amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), proposalId, + talerUri, }; } else if (!purchase.timestampFirstSuccessfulPay) { const download = await expectProposalDownload(ws, purchase); @@ -1407,6 +1422,7 @@ export async function checkPaymentByProposalId( amountRaw: Amounts.stringify(download.contractData.amount), amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), proposalId, + talerUri, }; } else { const paid = @@ -1423,6 +1439,7 @@ export async function checkPaymentByProposalId( amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), ...(paid ? { nextUrl: download.contractData.orderId } : {}), proposalId, + talerUri, }; } } @@ -1468,7 +1485,7 @@ export async function preparePayForUri( ); } - let proposalId = await startDownloadProposal( + const proposalId = await startDownloadProposal( ws, uriResult.merchantBaseUrl, uriResult.orderId, @@ -1930,6 +1947,28 @@ export async function processPurchasePay( ); } + if (resp.status === HttpStatusCode.Gone) { + const errDetails = await readUnexpectedResponseDetails(resp); + logger.warn("unexpected 410 response for /pay"); + logger.warn(j2s(errDetails)); + await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const purch = await tx.purchases.get(proposalId); + if (!purch) { + return; + } + // FIXME: Should be some "PayPermanentlyFailed" and error info should be stored + purch.purchaseStatus = PurchaseStatus.PaymentAbortFinished; + await tx.purchases.put(purch); + }); + throw makePendingOperationFailedError( + errDetails, + TransactionType.Payment, + proposalId, + ); + } + if (resp.status === HttpStatusCode.Conflict) { const err = await readTalerErrorResponse(resp); if ( |