#7741 share payment

save shared state in backup
if purchase is shared check before making the payment of before claim the order
already confirmed order can return without effective if coin selection was not made
sharePayment operation
This commit is contained in:
Sebastian 2023-07-03 12:42:44 -03:00
parent f47b5bd783
commit 5d76573ac0
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
6 changed files with 285 additions and 19 deletions

View File

@ -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?
*/

View File

@ -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,
});
});

View File

@ -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,
});
}
}

View File

@ -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<string> {
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<SharePaymentResult> {
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<TaskRunResult> {
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,

View File

@ -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;

View File

@ -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<Op extends WalletApiOperation>(
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);