diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index c12d0f2f7..6a7a26f2f 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1145,6 +1145,11 @@ export enum PurchaseStatus { * Proposal downloaded, but the user needs to accept/reject it. */ DialogProposed = 30, + /** + * Proposal shared to other wallet or read from other wallet + * the user needs to accept/reject it. + */ + DialogShared = 31, /** * The user has rejected the proposal. @@ -1270,6 +1275,12 @@ export interface PurchaseRecord { posConfirmation: string | undefined; + /** + * This purchase was created by sharing nonce or + * did the wallet made the nonce public + */ + shared: boolean; + /** * When was the purchase record created? */ diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index 23c6e787a..21ba5dc37 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -422,6 +422,9 @@ export async function exportBackup( case PurchaseStatus.PendingPaying: propStatus = BackupProposalStatus.Proposed; break; + case PurchaseStatus.DialogShared: + propStatus = BackupProposalStatus.Shared; + break; case PurchaseStatus.FailedClaim: case PurchaseStatus.AbortedIncompletePayment: propStatus = BackupProposalStatus.PermanentlyFailed; @@ -483,6 +486,7 @@ export async function exportBackup( repurchase_proposal_id: purch.repurchaseProposalId, download_session_id: purch.downloadSessionId, timestamp_proposed: purch.timestamp, + shared: purch.shared, }); }); diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 7f73a14b0..b161aa8f2 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -62,7 +62,11 @@ import { InternalWalletState } from "../../internal-wallet-state.js"; import { assertUnreachable } from "../../util/assertUnreachable.js"; import { checkLogicInvariant } from "../../util/invariants.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; -import { constructTombstone, makeCoinAvailable, TombstoneTag } from "../common.js"; +import { + constructTombstone, + makeCoinAvailable, + TombstoneTag, +} from "../common.js"; import { getExchangeDetails } from "../exchanges.js"; import { extractContractData } from "../pay-merchant.js"; import { provideBackupState } from "./state.js"; @@ -576,6 +580,9 @@ export async function importBackup( case BackupProposalStatus.Paid: proposalStatus = PurchaseStatus.Done; break; + case BackupProposalStatus.Shared: + proposalStatus = PurchaseStatus.DialogShared; + break; case BackupProposalStatus.Proposed: proposalStatus = PurchaseStatus.DialogProposed; break; @@ -702,6 +709,7 @@ export async function importBackup( repurchaseProposalId: backupPurchase.repurchase_proposal_id, purchaseStatus: proposalStatus, timestamp: backupPurchase.timestamp_proposed, + shared: backupPurchase.shared, }); } } diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index c74fcedcf..d53ee1b43 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -61,7 +61,10 @@ import { PreparePayResultType, randomBytes, RefreshReason, + SharePaymentResult, StartRefundQueryForUriResponse, + stringifyPaytoUri, + stringifyPayUri, stringifyTalerUri, TalerError, TalerErrorCode, @@ -542,7 +545,9 @@ async function processDownloadProposal( p.repurchaseProposalId = otherPurchase.proposalId; await tx.purchases.put(p); } else { - p.purchaseStatus = PurchaseStatus.DialogProposed; + p.purchaseStatus = p.shared + ? PurchaseStatus.DialogShared + : PurchaseStatus.DialogProposed; await tx.purchases.put(p); } const newTxState = computePayMerchantTransactionState(p); @@ -570,15 +575,22 @@ async function createPurchase( claimToken: string | undefined, noncePriv: string | undefined, ): Promise { - const oldProposal = await ws.db + const oldProposals = await ws.db .mktx((x) => [x.purchases]) .runReadOnly(async (tx) => { - return tx.purchases.indexes.byUrlAndOrderId.get([ + return tx.purchases.indexes.byUrlAndOrderId.getAll([ merchantBaseUrl, orderId, ]); }); + const oldProposal = oldProposals.find((p) => { + return ( + p.downloadSessionId === sessionId && + (!noncePriv || p.noncePriv === noncePriv) && + p.claimToken === claimToken + ); + }); /* If we have already claimed this proposal with the same sessionId * nonce and claim token, reuse it. */ if ( @@ -589,11 +601,42 @@ async function createPurchase( ) { // FIXME: This lacks proper error handling await processDownloadProposal(ws, oldProposal.proposalId); + + if (oldProposal.purchaseStatus === PurchaseStatus.DialogShared) { + const download = await expectProposalDownload(ws, oldProposal); + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + if (paid) { + //if this transaction was shared and the order is paid then it + //means that another wallet already paid the proposal + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(oldProposal.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId: oldProposal.proposalId, + }); + notifyTransition(ws, transactionId, transitionInfo); + } + } return oldProposal.proposalId; } let noncePair: EddsaKeypair; + let shared = false; if (noncePriv) { + shared = true; noncePair = { priv: noncePriv, pub: (await ws.cryptoApi.eddsaGetPublic({ priv: noncePriv })).pub, @@ -627,19 +670,12 @@ async function createPurchase( timestampLastRefundStatus: undefined, pendingRemovedCoinPubs: undefined, posConfirmation: undefined, + shared: shared, }; const transitionInfo = await ws.db .mktx((x) => [x.purchases]) .runReadWrite(async (tx) => { - const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([ - merchantBaseUrl, - orderId, - ]); - if (existingRecord) { - // Created concurrently - return undefined; - } await tx.purchases.put(proposalRecord); const oldTxState: TransactionState = { major: TransactionMajorState.None, @@ -983,7 +1019,11 @@ export async function checkPaymentByProposalId( return tx.purchases.get(proposalId); }); - if (!purchase || purchase.purchaseStatus === PurchaseStatus.DialogProposed) { + if ( + !purchase || + purchase.purchaseStatus === PurchaseStatus.DialogProposed || + purchase.purchaseStatus === PurchaseStatus.DialogShared + ) { // If not already paid, check if we could pay for it. const res = await selectPayCoinsNew(ws, { auditors: [], @@ -1007,7 +1047,6 @@ export async function checkPaymentByProposalId( contractTerms: d.contractTermsRaw, proposalId: proposal.proposalId, transactionId, - noncePriv: proposal.noncePriv, amountRaw: Amounts.stringify(d.contractData.amount), talerUri, balanceDetails: res.insufficientBalanceDetails, @@ -1023,7 +1062,6 @@ export async function checkPaymentByProposalId( contractTerms: d.contractTermsRaw, transactionId, proposalId: proposal.proposalId, - noncePriv: proposal.noncePriv, amountEffective: Amounts.stringify(totalCost), amountRaw: Amounts.stringify(res.coinSel.paymentAmount), contractTermsHash: d.contractData.contractTermsHash, @@ -1067,7 +1105,9 @@ export async function checkPaymentByProposalId( contractTermsHash: download.contractData.contractTermsHash, paid: true, amountRaw: Amounts.stringify(download.contractData.amount), - amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + amountEffective: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, transactionId, proposalId, talerUri, @@ -1080,7 +1120,9 @@ export async function checkPaymentByProposalId( contractTermsHash: download.contractData.contractTermsHash, paid: false, amountRaw: Amounts.stringify(download.contractData.amount), - amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + amountEffective: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, transactionId, proposalId, talerUri, @@ -1097,7 +1139,9 @@ export async function checkPaymentByProposalId( contractTermsHash: download.contractData.contractTermsHash, paid, amountRaw: Amounts.stringify(download.contractData.amount), - amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), + amountEffective: purchase.payInfo + ? Amounts.stringify(purchase.payInfo.totalPayCost) + : undefined, ...(paid ? { nextUrl: download.contractData.orderId } : {}), transactionId, proposalId, @@ -1406,6 +1450,7 @@ export async function confirmPay( } const oldTxState = computePayMerchantTransactionState(p); switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: case PurchaseStatus.DialogProposed: p.payInfo = { payCoinSelection: coinSelection, @@ -1480,6 +1525,8 @@ export async function processPurchase( return processPurchaseAbortingRefund(ws, purchase); case PurchaseStatus.PendingAcceptRefund: return processPurchaseAcceptRefund(ws, purchase); + case PurchaseStatus.DialogShared: + return processPurchaseDialogShared(ws, purchase); case PurchaseStatus.FailedClaim: case PurchaseStatus.Done: case PurchaseStatus.RepurchaseDetected: @@ -1540,6 +1587,41 @@ export async function processPurchasePay( checkDbInvariant(!!payInfo, "payInfo"); const download = await expectProposalDownload(ws, purchase); + + if (purchase.shared) { + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + + if (paid) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + return { + type: TaskRunResultType.Error, + errorDetail: makeErrorDetail(TalerErrorCode.WALLET_ORDER_ALREADY_PAID, { + orderId: purchase.orderId, + }), + }; + } + } + if (!purchase.merchantPaySig) { const payUrl = new URL( `orders/${download.contractData.orderId}/pay`, @@ -1681,7 +1763,10 @@ export async function refuseProposal( logger.trace(`proposal ${proposalId} not found, won't refuse proposal`); return undefined; } - if (proposal.purchaseStatus !== PurchaseStatus.DialogProposed) { + if ( + proposal.purchaseStatus !== PurchaseStatus.DialogProposed && + proposal.purchaseStatus !== PurchaseStatus.DialogShared + ) { return undefined; } const oldTxState = computePayMerchantTransactionState(proposal); @@ -1996,6 +2081,11 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Dialog, minor: TransactionMinorState.MerchantOrderProposed, }; + case PurchaseStatus.DialogShared: + return { + major: TransactionMajorState.Dialog, + minor: TransactionMinorState.MerchantOrderProposed, + }; // Final States case PurchaseStatus.AbortedProposalRefused: return { @@ -2078,6 +2168,8 @@ export function computePayMerchantTransactionActions( // Dialog States case PurchaseStatus.DialogProposed: return []; + case PurchaseStatus.DialogShared: + return []; // Final States case PurchaseStatus.AbortedProposalRefused: return [TransactionAction.Delete]; @@ -2096,6 +2188,140 @@ export function computePayMerchantTransactionActions( } } +export async function sharePayment( + ws: InternalWalletState, + merchantBaseUrl: string, + orderId: string, +): Promise { + const result = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.indexes.byUrlAndOrderId.get([ + merchantBaseUrl, + orderId, + ]); + if (!p) { + logger.warn("purchase does not exist anymore"); + return undefined; + } + if ( + p.purchaseStatus !== PurchaseStatus.DialogProposed && + p.purchaseStatus !== PurchaseStatus.DialogShared + ) { + //FIXME: purchase can be shared before being paid + return undefined; + } + if (p.purchaseStatus === PurchaseStatus.DialogProposed) { + p.purchaseStatus = PurchaseStatus.DialogShared; + p.shared = true; + tx.purchases.put(p); + } + + return { + nonce: p.noncePriv, + session: p.lastSessionId, + token: p.claimToken, + }; + }); + + if (result === undefined) { + throw Error("This purchase can't be shared"); + } + const privatePayUri = stringifyPayUri({ + merchantBaseUrl, + orderId, + sessionId: result.session ?? "", + noncePriv: result.nonce, + claimToken: result.token, + }); + return { privatePayUri }; +} + +async function checkIfOrderIsAlreadyPaid( + ws: InternalWalletState, + contract: WalletContractData, +) { + const requestUrl = new URL( + `orders/${contract.orderId}`, + contract.merchantBaseUrl, + ); + requestUrl.searchParams.set("h_contract", contract.contractTermsHash); + + requestUrl.searchParams.set("timeout_ms", "1000"); + + const resp = await ws.http.fetch(requestUrl.href); + if ( + resp.status === HttpStatusCode.Ok || + resp.status === HttpStatusCode.Accepted || + resp.status === HttpStatusCode.Found + ) { + return true; + } else if (resp.status === HttpStatusCode.PaymentRequired) { + return false; + } + //forbidden, not found, not acceptable + throw Error(`this order cant be paid: ${resp.status}`); +} + +async function processPurchaseDialogShared( + ws: InternalWalletState, + purchase: PurchaseRecord, +): Promise { + const proposalId = purchase.proposalId; + logger.trace(`processing dialog-shared for proposal ${proposalId}`); + + const taskId = constructTaskIdentifier({ + tag: PendingTaskType.Purchase, + proposalId, + }); + + // FIXME: Put this logic into runLongpollAsync? + if (ws.activeLongpoll[taskId]) { + return TaskRunResult.longpoll(); + } + const download = await expectProposalDownload(ws, purchase); + + if (purchase.purchaseStatus !== PurchaseStatus.DialogShared) { + return TaskRunResult.finished(); + } + + runLongpollAsync(ws, taskId, async (ct) => { + const paid = await checkIfOrderIsAlreadyPaid(ws, download.contractData); + if (paid) { + const transitionInfo = await ws.db + .mktx((x) => [x.purchases]) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + const oldTxState = computePayMerchantTransactionState(p); + p.purchaseStatus = PurchaseStatus.FailedClaim; + const newTxState = computePayMerchantTransactionState(p); + await tx.purchases.put(p); + return { oldTxState, newTxState }; + }); + const transactionId = constructTransactionIdentifier({ + tag: TransactionType.Payment, + proposalId, + }); + + notifyTransition(ws, transactionId, transitionInfo); + + return { + ready: true, + }; + } + + return { + ready: false, + }; + }); + + return TaskRunResult.longpoll(); +} + async function processPurchaseAutoRefund( ws: InternalWalletState, purchase: PurchaseRecord, diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts index cea548db6..e395237cf 100644 --- a/packages/taler-wallet-core/src/wallet-api-types.ts +++ b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -112,6 +112,8 @@ import { WithdrawFakebankRequest, WithdrawTestBalanceRequest, WithdrawUriInfoResponse, + SharePaymentRequest, + SharePaymentResult, } from "@gnu-taler/taler-util"; import { AuditorTrustRecord, WalletContractData } from "./db.js"; import { @@ -129,6 +131,7 @@ export enum WalletApiOperation { WithdrawTestkudos = "withdrawTestkudos", WithdrawTestBalance = "withdrawTestBalance", PreparePayForUri = "preparePayForUri", + SharePayment = "sharePayment", PreparePayForTemplate = "preparePayForTemplate", GetContractTermsDetails = "getContractTermsDetails", RunIntegrationTest = "runIntegrationTest", @@ -458,6 +461,12 @@ export type PreparePayForUriOp = { response: PreparePayResult; }; +export type SharePaymentOp = { + op: WalletApiOperation.SharePayment; + request: SharePaymentRequest; + response: SharePaymentResult; +}; + /** * Prepare to make a payment based on a taler://pay-template/ URI. */ @@ -984,6 +993,7 @@ export type WalletOperations = { [WalletApiOperation.GetVersion]: GetVersionOp; [WalletApiOperation.WithdrawFakebank]: WithdrawFakebankOp; [WalletApiOperation.PreparePayForUri]: PreparePayForUriOp; + [WalletApiOperation.SharePayment]: SharePaymentOp; [WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp; [WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp; [WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 11030af2b..ca86cbb14 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -117,6 +117,7 @@ import { parsePaytoUri, sampleWalletCoreTransactions, validateIban, + codecForSharePaymentRequest, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -203,6 +204,7 @@ import { getContractTermsDetails, preparePayForUri, processPurchase, + sharePayment, startQueryRefund, startRefundQueryForUri, } from "./operations/pay-merchant.js"; @@ -1207,6 +1209,11 @@ async function dispatchRequestInternal( await runPending(ws); return {}; } + case WalletApiOperation.SharePayment: { + const req = codecForSharePaymentRequest().decode(payload); + return await sharePayment(ws, req.merchantBaseUrl, req.orderId); + } + case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(ws, req.talerPayUri);