wallet-core: fix tipping state machine issues

This commit is contained in:
Florian Dold 2023-06-02 15:53:46 +02:00
parent 2f4f43cc1f
commit 1ee9ef80bd
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
3 changed files with 82 additions and 47 deletions

View File

@ -876,7 +876,9 @@ export interface TipRecord {
export enum TipRecordStatus { export enum TipRecordStatus {
PendingPickup = 10, PendingPickup = 10,
SuspendidPickup = 21, SuspendidPickup = 20,
DialogAccept = 30,
Done = 50, Done = 50,
Aborted = 51, Aborted = 51,

View File

@ -532,21 +532,23 @@ async function processDownloadProposal(
h: contractTermsHash, h: contractTermsHash,
contractTermsRaw: proposalResp.contract_terms, contractTermsRaw: proposalResp.contract_terms,
}); });
if ( const isResourceFulfillmentUrl =
fulfillmentUrl && fulfillmentUrl &&
(fulfillmentUrl.startsWith("http://") || (fulfillmentUrl.startsWith("http://") ||
fulfillmentUrl.startsWith("https://")) fulfillmentUrl.startsWith("https://"));
) { let otherPurchase: PurchaseRecord | undefined;
const differentPurchase = if (isResourceFulfillmentUrl) {
await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl); otherPurchase = await tx.purchases.indexes.byFulfillmentUrl.get(
// FIXME: Adjust this to account for refunds, don't count as repurchase fulfillmentUrl,
// if original order is refunded. );
if (differentPurchase) { }
logger.warn("repurchase detected"); // FIXME: Adjust this to account for refunds, don't count as repurchase
p.purchaseStatus = PurchaseStatus.RepurchaseDetected; // if original order is refunded.
p.repurchaseProposalId = differentPurchase.proposalId; if (otherPurchase) {
await tx.purchases.put(p); logger.warn("repurchase detected");
} p.purchaseStatus = PurchaseStatus.RepurchaseDetected;
p.repurchaseProposalId = otherPurchase.proposalId;
await tx.purchases.put(p);
} else { } else {
p.purchaseStatus = PurchaseStatus.DialogProposed; p.purchaseStatus = PurchaseStatus.DialogProposed;
await tx.purchases.put(p); await tx.purchases.put(p);
@ -602,6 +604,7 @@ async function createPurchase(
(!noncePriv || oldProposal.noncePriv === noncePriv) && (!noncePriv || oldProposal.noncePriv === noncePriv) &&
oldProposal.claimToken === claimToken oldProposal.claimToken === claimToken
) { ) {
// FIXME: This lacks proper error handling
await processDownloadProposal(ws, oldProposal.proposalId); await processDownloadProposal(ws, oldProposal.proposalId);
return oldProposal.proposalId; return oldProposal.proposalId;
} }

View File

@ -34,7 +34,6 @@ import {
PrepareTipResult, PrepareTipResult,
TalerErrorCode, TalerErrorCode,
TalerPreciseTimestamp, TalerPreciseTimestamp,
TalerProtocolTimestamp,
TipPlanchetDetail, TipPlanchetDetail,
TransactionAction, TransactionAction,
TransactionMajorState, TransactionMajorState,
@ -102,17 +101,21 @@ export function computeTipTransactionStatus(
major: TransactionMajorState.Pending, major: TransactionMajorState.Pending,
minor: TransactionMinorState.Pickup, minor: TransactionMinorState.Pickup,
}; };
case TipRecordStatus.DialogAccept:
return {
major: TransactionMajorState.Dialog,
minor: TransactionMinorState.Proposed,
};
case TipRecordStatus.SuspendidPickup: case TipRecordStatus.SuspendidPickup:
return { return {
major: TransactionMajorState.Pending, major: TransactionMajorState.Pending,
minor: TransactionMinorState.User, minor: TransactionMinorState.Pickup,
}; };
default: default:
assertUnreachable(tipRecord.status); assertUnreachable(tipRecord.status);
} }
} }
export function computeTipTransactionActions( export function computeTipTransactionActions(
tipRecord: TipRecord, tipRecord: TipRecord,
): TransactionAction[] { ): TransactionAction[] {
@ -125,6 +128,8 @@ export function computeTipTransactionActions(
return [TransactionAction.Suspend, TransactionAction.Fail]; return [TransactionAction.Suspend, TransactionAction.Fail];
case TipRecordStatus.SuspendidPickup: case TipRecordStatus.SuspendidPickup:
return [TransactionAction.Resume, TransactionAction.Fail]; return [TransactionAction.Resume, TransactionAction.Fail];
case TipRecordStatus.DialogAccept:
return [TransactionAction.Abort];
default: default:
assertUnreachable(tipRecord.status); assertUnreachable(tipRecord.status);
} }
@ -190,7 +195,7 @@ export async function prepareTip(
const newTipRecord: TipRecord = { const newTipRecord: TipRecord = {
walletTipId: walletTipId, walletTipId: walletTipId,
acceptedTimestamp: undefined, acceptedTimestamp: undefined,
status: TipRecordStatus.PendingPickup, status: TipRecordStatus.DialogAccept,
tipAmountRaw: Amounts.stringify(amount), tipAmountRaw: Amounts.stringify(amount),
tipExpiration: tipPickupStatus.expiration, tipExpiration: tipPickupStatus.expiration,
exchangeBaseUrl: tipPickupStatus.exchange_url, exchangeBaseUrl: tipPickupStatus.exchange_url,
@ -234,7 +239,6 @@ export async function prepareTip(
export async function processTip( export async function processTip(
ws: InternalWalletState, ws: InternalWalletState,
walletTipId: string, walletTipId: string,
options: Record<string, never> = {},
): Promise<OperationAttemptResult> { ): Promise<OperationAttemptResult> {
const tipRecord = await ws.db const tipRecord = await ws.db
.mktx((x) => [x.tips]) .mktx((x) => [x.tips])
@ -248,14 +252,22 @@ export async function processTip(
}; };
} }
if (tipRecord.pickedUpTimestamp) { switch (tipRecord.status) {
logger.warn("tip already picked up"); case TipRecordStatus.Aborted:
return { case TipRecordStatus.DialogAccept:
type: OperationAttemptResultType.Finished, case TipRecordStatus.Done:
result: undefined, case TipRecordStatus.SuspendidPickup:
}; return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
} }
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
});
const denomsForWithdraw = tipRecord.denomsSel; const denomsForWithdraw = tipRecord.denomsSel;
const planchets: DerivedTipPlanchet[] = []; const planchets: DerivedTipPlanchet[] = [];
@ -391,22 +403,27 @@ export async function processTip(
}); });
} }
await ws.db const transitionInfo = await ws.db
.mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.tips]) .mktx((x) => [x.coins, x.coinAvailability, x.denominations, x.tips])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const tr = await tx.tips.get(walletTipId); const tr = await tx.tips.get(walletTipId);
if (!tr) { if (!tr) {
return; return;
} }
if (tr.pickedUpTimestamp) { if (tr.status !== TipRecordStatus.PendingPickup) {
return; return;
} }
const oldTxState = computeTipTransactionStatus(tr);
tr.pickedUpTimestamp = TalerPreciseTimestamp.now(); tr.pickedUpTimestamp = TalerPreciseTimestamp.now();
tr.status = TipRecordStatus.Done;
await tx.tips.put(tr); await tx.tips.put(tr);
const newTxState = computeTipTransactionStatus(tr);
for (const cr of newCoinRecords) { for (const cr of newCoinRecords) {
await makeCoinAvailable(ws, tx, cr); await makeCoinAvailable(ws, tx, cr);
} }
return { oldTxState, newTxState };
}); });
notifyTransition(ws, transactionId, transitionInfo);
return { return {
type: OperationAttemptResultType.Finished, type: OperationAttemptResultType.Finished,
@ -416,33 +433,46 @@ export async function processTip(
export async function acceptTip( export async function acceptTip(
ws: InternalWalletState, ws: InternalWalletState,
tipId: string, walletTipId: string,
): Promise<AcceptTipResponse> { ): Promise<AcceptTipResponse> {
const found = await ws.db const transactionId = constructTransactionIdentifier({
tag: TransactionType.Tip,
walletTipId,
});
const dbRes = await ws.db
.mktx((x) => [x.tips]) .mktx((x) => [x.tips])
.runReadWrite(async (tx) => { .runReadWrite(async (tx) => {
const tipRecord = await tx.tips.get(tipId); const tipRecord = await tx.tips.get(walletTipId);
if (!tipRecord) { if (!tipRecord) {
logger.error("tip not found"); logger.error("tip not found");
return undefined; return;
} }
if (tipRecord.status != TipRecordStatus.DialogAccept) {
logger.warn("Unable to accept tip in the current state");
return { tipRecord };
}
const oldTxState = computeTipTransactionStatus(tipRecord);
tipRecord.acceptedTimestamp = TalerPreciseTimestamp.now(); tipRecord.acceptedTimestamp = TalerPreciseTimestamp.now();
tipRecord.status = TipRecordStatus.PendingPickup;
await tx.tips.put(tipRecord); await tx.tips.put(tipRecord);
const newTxState = computeTipTransactionStatus(tipRecord);
return tipRecord; return { tipRecord, transitionInfo: { oldTxState, newTxState } };
}); });
if (found) { if (!dbRes) {
await processTip(ws, tipId); throw Error("tip not found");
} }
//FIXME: if tip is not found the behavior of the function is the same
// as the tip was found and finished notifyTransition(ws, transactionId, dbRes.transitionInfo);
const tipRecord = dbRes.tipRecord;
return { return {
transactionId: constructTransactionIdentifier({ transactionId: constructTransactionIdentifier({
tag: TransactionType.Tip, tag: TransactionType.Tip,
walletTipId: tipId, walletTipId: walletTipId,
}), }),
next_url: found?.next_url, next_url: tipRecord.next_url,
}; };
} }
@ -472,10 +502,12 @@ export async function suspendTipTransaction(
case TipRecordStatus.Done: case TipRecordStatus.Done:
case TipRecordStatus.SuspendidPickup: case TipRecordStatus.SuspendidPickup:
case TipRecordStatus.Aborted: case TipRecordStatus.Aborted:
case TipRecordStatus.DialogAccept:
break; break;
case TipRecordStatus.PendingPickup: case TipRecordStatus.PendingPickup:
newStatus = TipRecordStatus.SuspendidPickup; newStatus = TipRecordStatus.SuspendidPickup;
break; break;
default: default:
assertUnreachable(tipRec.status); assertUnreachable(tipRec.status);
} }
@ -519,14 +551,13 @@ export async function resumeTipTransaction(
let newStatus: TipRecordStatus | undefined = undefined; let newStatus: TipRecordStatus | undefined = undefined;
switch (tipRec.status) { switch (tipRec.status) {
case TipRecordStatus.Done: case TipRecordStatus.Done:
case TipRecordStatus.PendingPickup:
case TipRecordStatus.Aborted:
case TipRecordStatus.DialogAccept:
break; break;
case TipRecordStatus.SuspendidPickup: case TipRecordStatus.SuspendidPickup:
newStatus = TipRecordStatus.PendingPickup; newStatus = TipRecordStatus.PendingPickup;
break; break;
case TipRecordStatus.PendingPickup:
break;
case TipRecordStatus.Aborted:
break;
default: default:
assertUnreachable(tipRec.status); assertUnreachable(tipRec.status);
} }
@ -577,14 +608,13 @@ export async function abortTipTransaction(
let newStatus: TipRecordStatus | undefined = undefined; let newStatus: TipRecordStatus | undefined = undefined;
switch (tipRec.status) { switch (tipRec.status) {
case TipRecordStatus.Done: case TipRecordStatus.Done:
case TipRecordStatus.Aborted:
case TipRecordStatus.PendingPickup:
case TipRecordStatus.DialogAccept:
break; break;
case TipRecordStatus.SuspendidPickup: case TipRecordStatus.SuspendidPickup:
newStatus = TipRecordStatus.Aborted; newStatus = TipRecordStatus.Aborted;
break; break;
case TipRecordStatus.PendingPickup:
break;
case TipRecordStatus.Aborted:
break;
default: default:
assertUnreachable(tipRec.status); assertUnreachable(tipRec.status);
} }