wallet-core: Clean up merchant payments DB schema

This commit is contained in:
Florian Dold 2022-10-08 20:56:57 +02:00
parent eace0e0e7a
commit 526f4eba95
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
30 changed files with 1833 additions and 2076 deletions

View File

@ -20,6 +20,12 @@
"typescript.format.placeOpenBraceOnNewLineForFunctions": false,
// Defines whether an open brace is put onto a new line for control blocks or not
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
"typescript.preferences.autoImportFileExcludePatterns": [
"index.js",
"index.*.js",
"index.ts",
"index.*.ts"
],
// Files hidden in the explorer
"files.exclude": {
// include the defaults from VS Code

View File

@ -378,9 +378,9 @@ export class MemoryBackend implements Backend {
}
}
private makeObjectStoreMap(
database: Database,
): { [currentName: string]: ObjectStoreMapEntry } {
private makeObjectStoreMap(database: Database): {
[currentName: string]: ObjectStoreMapEntry;
} {
let map: { [currentName: string]: ObjectStoreMapEntry } = {};
for (let objectStoreName in database.committedObjectStores) {
const store = database.committedObjectStores[objectStoreName];
@ -1088,9 +1088,8 @@ export class MemoryBackend implements Backend {
if (!existingIndexRecord) {
throw Error("db inconsistent: expected index entry missing");
}
const newPrimaryKeys = existingIndexRecord.primaryKeys.without(
primaryKey,
);
const newPrimaryKeys =
existingIndexRecord.primaryKeys.without(primaryKey);
if (newPrimaryKeys.size === 0) {
index.modifiedData = indexData.without(indexKey);
} else {
@ -1357,7 +1356,20 @@ export class MemoryBackend implements Backend {
// Remove old index entry first!
if (oldStoreRecord) {
this.deleteFromIndex(index, key, oldStoreRecord.value, indexProperties);
try {
this.deleteFromIndex(
index,
key,
oldStoreRecord.value,
indexProperties,
);
} catch (e) {
if (e instanceof DataError) {
// Do nothing
} else {
throw e;
}
}
}
try {
this.insertIntoIndex(index, key, value, indexProperties);

View File

@ -180,15 +180,6 @@ export interface WalletBackupContentV1 {
*/
tips: BackupTip[];
/**
* Proposals from merchants. The proposal may
* be deleted as soon as it has been accepted (and thus
* turned into a purchase).
*
* Sorted by the proposal ID.
*/
proposals: BackupProposal[];
/**
* Accepted purchases.
*
@ -838,29 +829,10 @@ export type BackupRefundItem =
| BackupRefundPendingItem
| BackupRefundAppliedItem;
export interface BackupPurchase {
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
/**
* Data we store when the payment was accepted.
*/
proposal_id: string;
/**
* Contract terms we got from the merchant.
*/
contract_terms_raw: RawContractTerms;
/**
* Signature on the contract terms.
*/
merchant_sig: string;
/**
* Private key for the nonce. Might eventually be used
* to prove ownership of the contract.
*/
nonce_priv: string;
export interface BackupPayInfo {
pay_coins: {
/**
* Public keys of the coins that were selected.
@ -890,6 +862,63 @@ export interface BackupPurchase {
* We might show adjustments to this later, but currently we don't do so.
*/
total_pay_cost: BackupAmountString;
}
export interface BackupPurchase {
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
*/
proposal_id: string;
/**
* Status of the proposal.
*/
proposal_status: BackupProposalStatus;
/**
* Proposal that this one got "redirected" to as part of
* the repurchase detection.
*/
repurchase_proposal_id: string | undefined;
/**
* Session ID we got when downloading the contract.
*/
download_session_id?: string;
/**
* Merchant-assigned order ID of the proposal.
*/
order_id: string;
/**
* Base URL of the merchant that proposed the purchase.
*/
merchant_base_url: string;
/**
* Claim token initially given by the merchant.
*/
claim_token: string | undefined;
/**
* Contract terms we got from the merchant.
*/
contract_terms_raw?: RawContractTerms;
/**
* Signature on the contract terms.
*/
merchant_sig?: string;
/**
* Private key for the nonce. Might eventually be used
* to prove ownership of the contract.
*/
nonce_priv: string;
pay_info: BackupPayInfo | undefined;
/**
* Timestamp of the first time that sending a payment to the merchant
@ -902,11 +931,13 @@ export interface BackupPurchase {
*/
merchant_pay_sig: string | undefined;
timestamp_proposed: TalerProtocolTimestamp;
/**
* When was the purchase made?
* Refers to the time that the user accepted.
*/
timestamp_accept: TalerProtocolTimestamp;
timestamp_accepted: TalerProtocolTimestamp | undefined;
/**
* Pending refunds for the purchase. A refund is pending
@ -914,11 +945,6 @@ export interface BackupPurchase {
*/
refunds: BackupRefundItem[];
/**
* Abort status of the payment.
*/
abort_status?: "abort-refund" | "abort-finished";
/**
* Continue querying the refund status until this deadline has expired.
*/
@ -1218,70 +1244,8 @@ export enum BackupProposalStatus {
* Downloaded proposal was detected as a re-purchase.
*/
Repurchase = "repurchase",
}
/**
* Proposal by a merchant.
*/
export interface BackupProposal {
/**
* Base URL of the merchant that proposed the purchase.
*/
merchant_base_url: string;
/**
* Downloaded data from the merchant.
*/
contract_terms_raw?: RawContractTerms;
/**
* Signature on the contract terms.
*
* Must be present if contract_terms_raw is present.
*/
merchant_sig?: string;
/**
* Unique ID when the order is stored in the wallet DB.
*/
proposal_id: string;
/**
* Merchant-assigned order ID of the proposal.
*/
order_id: string;
/**
* Timestamp of when the record
* was created.
*/
timestamp: TalerProtocolTimestamp;
/**
* Private key for the nonce.
*/
nonce_priv: string;
/**
* Claim token initially given by the merchant.
*/
claim_token: string | undefined;
/**
* Status of the proposal.
*/
proposal_status: BackupProposalStatus;
/**
* Proposal that this one got "redirected" to as part of
* the repurchase detection.
*/
repurchase_proposal_id: string | undefined;
/**
* Session ID we got when downloading the contract.
*/
download_session_id?: string;
Paid = "paid",
}
export interface BackupRecovery {

View File

@ -17,13 +17,8 @@
/**
* Imports.
*/
import {
PreparePayResultType,
TalerErrorCode,
TalerErrorDetail,
} from "@gnu-taler/taler-util";
import { PreparePayResultType, TalerErrorCode } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { makeEventId } from "@gnu-taler/taler-wallet-core";
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
import {
createSimpleTestkudosEnvironment,

View File

@ -18,7 +18,10 @@
* Imports.
*/
import { GlobalTestState, MerchantPrivateApi } from "../harness/harness.js";
import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js";
import {
createSimpleTestkudosEnvironment,
withdrawViaBank,
} from "../harness/helpers.js";
import { PreparePayResultType } from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
@ -29,12 +32,8 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
export async function runPaymentIdempotencyTest(t: GlobalTestState) {
// Set up test environment
const {
wallet,
bank,
exchange,
merchant,
} = await createSimpleTestkudosEnvironment(t);
const { wallet, bank, exchange, merchant } =
await createSimpleTestkudosEnvironment(t);
// Withdraw digital cash into the wallet.
@ -83,10 +82,16 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
const proposalId = preparePayResult.proposalId;
await wallet.client.call(WalletApiOperation.ConfirmPay, {
// FIXME: should be validated, don't cast!
const confirmPayResult = await wallet.client.call(
WalletApiOperation.ConfirmPay,
{
proposalId: proposalId,
});
},
);
console.log("confirm pay result", confirmPayResult);
await wallet.runUntilDone();
// Check if payment was successful.
@ -103,6 +108,8 @@ export async function runPaymentIdempotencyTest(t: GlobalTestState) {
},
);
console.log("result after:", preparePayResultAfter);
t.assertTrue(
preparePayResultAfter.status === PreparePayResultType.AlreadyConfirmed,
);

View File

@ -34,11 +34,7 @@ import {
TalerErrorCode,
} from "@gnu-taler/taler-util";
import { TalerError } from "./errors.js";
import {
HttpRequestLibrary,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
} from "./index.browser.js";
import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
const logger = new Logger("bank-api-client.ts");

View File

@ -98,11 +98,11 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
*/
export const WALLET_DB_MINOR_VERSION = 2;
export namespace OperationStatusRange {
export const ACTIVE_START = 10;
export const ACTIVE_END = 29;
export const DORMANT_START = 50;
export const DORMANT_END = 69;
export enum OperationStatusRange {
ACTIVE_START = 10,
ACTIVE_END = 29,
DORMANT_START = 50,
DORMANT_END = 69,
}
/**
@ -741,93 +741,6 @@ export interface CoinAllocation {
amount: AmountString;
}
export enum ProposalStatus {
/**
* Not downloaded yet.
*/
Downloading = "downloading",
/**
* Proposal downloaded, but the user needs to accept/reject it.
*/
Proposed = "proposed",
/**
* The user has accepted the proposal.
*/
Accepted = "accepted",
/**
* The user has rejected the proposal.
*/
Refused = "refused",
/**
* Downloading or processing the proposal has failed permanently.
*/
PermanentlyFailed = "permanently-failed",
/**
* Downloaded proposal was detected as a re-purchase.
*/
Repurchase = "repurchase",
}
export interface ProposalDownload {
/**
* The contract that was offered by the merchant.
*/
contractTermsRaw: any;
/**
* Extracted / parsed data from the contract terms.
*
* FIXME: Do we need to store *all* that data in duplicate?
*/
contractData: WalletContractData;
}
/**
* Record for a downloaded order, stored in the wallet's database.
*/
export interface ProposalRecord {
orderId: string;
merchantBaseUrl: string;
/**
* Downloaded data from the merchant.
*/
download: ProposalDownload | undefined;
/**
* Unique ID when the order is stored in the wallet DB.
*/
proposalId: string;
/**
* Timestamp (in ms) of when the record
* was created.
*/
timestamp: TalerProtocolTimestamp;
/**
* Private key for the nonce.
*/
noncePriv: string;
/**
* Public key for the nonce.
*/
noncePub: string;
claimToken: string | undefined;
proposalStatus: ProposalStatus;
repurchaseProposalId: string | undefined;
/**
* Session ID we got when downloading the contract.
*/
downloadSessionId: string | undefined;
}
/**
* Status of a tip we got from a merchant.
*/
@ -1113,23 +1026,132 @@ export interface WalletContractData {
deliveryLocation: Location | undefined;
}
export enum AbortStatus {
None = "none",
AbortRefund = "abort-refund",
AbortFinished = "abort-finished",
export enum ProposalStatus {
/**
* Not downloaded yet.
*/
DownloadingProposal = OperationStatusRange.ACTIVE_START,
/**
* The user has accepted the proposal.
*/
Paying = OperationStatusRange.ACTIVE_START + 1,
AbortingWithRefund = OperationStatusRange.ACTIVE_START + 2,
/**
* Paying a second time, likely with different session ID
*/
PayingReplay = OperationStatusRange.ACTIVE_START + 3,
/**
* Query for refunds (until query succeeds).
*/
QueryingRefund = OperationStatusRange.ACTIVE_START + 4,
/**
* Query for refund (until auto-refund deadline is reached).
*/
QueryingAutoRefund = OperationStatusRange.ACTIVE_START + 5,
/**
* Proposal downloaded, but the user needs to accept/reject it.
*/
Proposed = OperationStatusRange.DORMANT_START,
/**
* The user has rejected the proposal.
*/
ProposalRefused = OperationStatusRange.DORMANT_START + 1,
/**
* Downloading or processing the proposal has failed permanently.
*/
ProposalDownloadFailed = OperationStatusRange.DORMANT_START + 2,
/**
* Downloaded proposal was detected as a re-purchase.
*/
RepurchaseDetected = OperationStatusRange.DORMANT_START + 3,
/**
* The payment has been aborted.
*/
PaymentAbortFinished = OperationStatusRange.DORMANT_START + 4,
/**
* Payment was successful.
*/
Paid = OperationStatusRange.DORMANT_START + 5,
}
export interface ProposalDownload {
/**
* The contract that was offered by the merchant.
*/
contractTermsRaw: any;
/**
* Extracted / parsed data from the contract terms.
*
* FIXME: Do we need to store *all* that data in duplicate?
*/
contractData: WalletContractData;
}
export interface PurchasePayInfo {
payCoinSelection: PayCoinSelection;
totalPayCost: AmountJson;
payCoinSelectionUid: string;
/**
* Deposit permissions, available once the user has accepted the payment.
*
* This value is cached and derived from payCoinSelection.
*
* FIXME: Should probably be cached somewhere else, maybe not even in DB!
*/
coinDepositPermissions: CoinDepositPermission[] | undefined;
}
/**
* Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable.
*
* FIXME: Should have a single "status" field.
*/
export interface PurchaseRecord {
/**
* Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal.
* Assigned by the wallet.
*/
proposalId: string;
/**
* Order ID, assigned by the merchant.
*/
orderId: string;
merchantBaseUrl: string;
/**
* Claim token used when downloading the contract terms.
*/
claimToken: string | undefined;
/**
* Session ID we got when downloading the contract.
*/
downloadSessionId: string | undefined;
/**
* If this purchase is a repurchase, this field identifies the original purchase.
*/
repurchaseProposalId: string | undefined;
status: ProposalStatus;
/**
* Private key for the nonce.
*/
@ -1146,18 +1168,9 @@ export interface PurchaseRecord {
* FIXME: Move this into another object store,
* to improve read/write perf on purchases.
*/
download: ProposalDownload;
download: ProposalDownload | undefined;
/**
* Deposit permissions, available once the user has accepted the payment.
*
* This value is cached and derived from payCoinSelection.
*/
coinDepositPermissions: CoinDepositPermission[] | undefined;
payCoinSelection: PayCoinSelection;
payCoinSelectionUid: string;
payInfo: PurchasePayInfo | undefined;
/**
* Pending removals from pay coin selection.
@ -1169,8 +1182,6 @@ export interface PurchaseRecord {
*/
pendingRemovedCoinPubs?: string[];
totalPayCost: AmountJson;
/**
* Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful.
@ -1181,11 +1192,16 @@ export interface PurchaseRecord {
merchantPaySig: string | undefined;
/**
* When was the purchase record created?
*/
timestamp: TalerProtocolTimestamp;
/**
* When was the purchase made?
* Refers to the time that the user accepted.
*/
timestampAccept: TalerProtocolTimestamp;
timestampAccept: TalerProtocolTimestamp | undefined;
/**
* Pending refunds for the purchase. A refund is pending
@ -1206,18 +1222,6 @@ export interface PurchaseRecord {
*/
lastSessionId: string | undefined;
/**
* Do we still need to post the deposit permissions to the merchant?
* Set for the first payment, or on re-plays.
*/
paymentSubmitPending: boolean;
/**
* Do we need to query the merchant for the refund status
* of the payment?
*/
refundQueryRequested: boolean;
/**
* Continue querying the refund status until this deadline has expired.
*/
@ -1227,18 +1231,7 @@ export interface PurchaseRecord {
* How much merchant has refund to be taken but the wallet
* did not picked up yet
*/
refundAwaiting: AmountJson | undefined;
/**
* Is the payment frozen? I.e. did we encounter
* an error where it doesn't make sense to retry.
*/
payFrozen?: boolean;
/**
* FIXME: How does this interact with payFrozen?
*/
abortStatus: AbortStatus;
refundAmountAwaiting: AmountJson | undefined;
}
export const WALLET_BACKUP_STATE_KEY = "walletBackupState";
@ -1923,16 +1916,6 @@ export const WalletStoresV1 = {
}),
{},
),
proposals: describeStore(
"proposals",
describeContents<ProposalRecord>({ keyPath: "proposalId" }),
{
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
"merchantBaseUrl",
"orderId",
]),
},
),
refreshGroups: describeStore(
"refreshGroups",
describeContents<RefreshGroupRecord>({
@ -1953,14 +1936,20 @@ export const WalletStoresV1 = {
"purchases",
describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
{
byStatus: describeIndex("byStatus", "operationStatus"),
byFulfillmentUrl: describeIndex(
"byFulfillmentUrl",
"download.contractData.fulfillmentUrl",
),
// FIXME: Deduplicate!
byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
"download.contractData.merchantBaseUrl",
"download.contractData.orderId",
]),
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
"merchantBaseUrl",
"orderId",
]),
},
),
tips: describeStore(

View File

@ -26,6 +26,9 @@
* Imports.
*/
import {
AbsoluteTime,
AgeRestriction,
AmountJson,
Amounts,
AmountString,
codecForAny,
@ -35,7 +38,6 @@ import {
codecForExchangeRevealResponse,
codecForWithdrawResponse,
DenominationPubKey,
eddsaGetPublic,
encodeCrock,
ExchangeMeltRequest,
ExchangeProtocolVersion,
@ -44,29 +46,15 @@ import {
hashWire,
Logger,
parsePaytoUri,
AbsoluteTime,
UnblindedSignature,
BankWithdrawDetails,
parseWithdrawUri,
AmountJson,
AgeRestriction,
} from "@gnu-taler/taler-util";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
import { DenominationRecord } from "./db.js";
import {
assembleRefreshRevealRequest,
ExchangeInfo,
getBankWithdrawalInfo,
HttpRequestLibrary,
isWithdrawableDenom,
readSuccessResponseJsonOrThrow,
} from "./index.browser.js";
import {
BankAccessApi,
BankApi,
BankServiceHandle,
getBankStatusUrl,
} from "./index.js";
import { BankAccessApi, BankApi, BankServiceHandle } from "./bank-api-client.js";
import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
import { getBankStatusUrl, getBankWithdrawalInfo, isWithdrawableDenom } from "./operations/withdraw.js";
import { ExchangeInfo } from "./operations/exchanges.js";
import { assembleRefreshRevealRequest } from "./operations/refresh.js";
const logger = new Logger("dbless.ts");

View File

@ -47,7 +47,6 @@ export * from "./wallet-api-types.js";
export * from "./wallet.js";
export * from "./operations/backup/index.js";
export { makeEventId } from "./operations/transactions.js";
export * from "./operations/exchanges.js";

View File

@ -37,6 +37,9 @@ import {
TalerProtocolTimestamp,
CancellationToken,
DenominationInfo,
RefreshGroupId,
CoinPublicKey,
RefreshReason,
} from "@gnu-taler/taler-util";
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
@ -74,6 +77,20 @@ export interface MerchantOperations {
): Promise<MerchantInfo>;
}
export interface RefreshOperations {
createRefreshGroup(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
denominations: typeof WalletStoresV1.denominations;
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
coinAvailability: typeof WalletStoresV1.coinAvailability;
}>,
oldCoinPubs: CoinPublicKey[],
reason: RefreshReason,
): Promise<RefreshGroupId>;
}
/**
* Interface for exchange-related operations.
*/
@ -172,6 +189,7 @@ export interface InternalWalletState {
exchangeOps: ExchangeOperations;
recoupOps: RecoupOperations;
merchantOps: MerchantOperations;
refreshOps: RefreshOperations;
getDenomInfo(
ws: InternalWalletState,

View File

@ -37,7 +37,7 @@ import {
BackupExchangeDetails,
BackupExchangeWireFee,
BackupOperationStatus,
BackupProposal,
BackupPayInfo,
BackupProposalStatus,
BackupPurchase,
BackupRecoupGroup,
@ -62,11 +62,9 @@ import {
WalletBackupContentV1,
} from "@gnu-taler/taler-util";
import {
AbortStatus,
CoinSourceType,
CoinStatus,
DenominationRecord,
OperationStatus,
ProposalStatus,
RefreshCoinStatus,
RefundState,
@ -92,7 +90,6 @@ export async function exportBackup(
x.coins,
x.denominations,
x.purchases,
x.proposals,
x.refreshGroups,
x.backupProviders,
x.tips,
@ -109,7 +106,6 @@ export async function exportBackup(
[url: string]: BackupDenomination[];
} = {};
const backupPurchases: BackupPurchase[] = [];
const backupProposals: BackupProposal[] = [];
const backupRefreshGroups: BackupRefreshGroup[] = [];
const backupBackupProviders: BackupBackupProvider[] = [];
const backupTips: BackupTip[] = [];
@ -385,65 +381,61 @@ export async function exportBackup(
}
}
backupPurchases.push({
contract_terms_raw: purch.download.contractTermsRaw,
auto_refund_deadline: purch.autoRefundDeadline,
merchant_pay_sig: purch.merchantPaySig,
pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
coin_pub: x,
contribution: Amounts.stringify(
purch.payCoinSelection.coinContributions[i],
),
})),
proposal_id: purch.proposalId,
refunds,
timestamp_accept: purch.timestampAccept,
timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
abort_status:
purch.abortStatus === AbortStatus.None
? undefined
: purch.abortStatus,
nonce_priv: purch.noncePriv,
merchant_sig: purch.download.contractData.merchantSig,
total_pay_cost: Amounts.stringify(purch.totalPayCost),
pay_coins_uid: purch.payCoinSelectionUid,
});
});
await tx.proposals.iter().forEach((prop) => {
if (purchaseProposalIdSet.has(prop.proposalId)) {
return;
}
let propStatus: BackupProposalStatus;
switch (prop.proposalStatus) {
case ProposalStatus.Accepted:
switch (purch.status) {
case ProposalStatus.Paid:
propStatus = BackupProposalStatus.Paid;
return;
case ProposalStatus.Downloading:
case ProposalStatus.DownloadingProposal:
case ProposalStatus.Proposed:
propStatus = BackupProposalStatus.Proposed;
break;
case ProposalStatus.PermanentlyFailed:
case ProposalStatus.ProposalDownloadFailed:
propStatus = BackupProposalStatus.PermanentlyFailed;
break;
case ProposalStatus.Refused:
case ProposalStatus.ProposalRefused:
propStatus = BackupProposalStatus.Refused;
break;
case ProposalStatus.Repurchase:
case ProposalStatus.RepurchaseDetected:
propStatus = BackupProposalStatus.Repurchase;
break;
default:
throw Error();
}
backupProposals.push({
claim_token: prop.claimToken,
nonce_priv: prop.noncePriv,
proposal_id: prop.noncePriv,
const payInfo = purch.payInfo;
let backupPayInfo: BackupPayInfo | undefined = undefined;
if (payInfo) {
backupPayInfo = {
pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({
coin_pub: x,
contribution: Amounts.stringify(
payInfo.payCoinSelection.coinContributions[i],
),
})),
total_pay_cost: Amounts.stringify(payInfo.totalPayCost),
pay_coins_uid: payInfo.payCoinSelectionUid,
};
}
backupPurchases.push({
contract_terms_raw: purch.download?.contractTermsRaw,
auto_refund_deadline: purch.autoRefundDeadline,
merchant_pay_sig: purch.merchantPaySig,
pay_info: backupPayInfo,
proposal_id: purch.proposalId,
refunds,
timestamp_accepted: purch.timestampAccept,
timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
nonce_priv: purch.noncePriv,
merchant_sig: purch.download?.contractData.merchantSig,
claim_token: purch.claimToken,
merchant_base_url: purch.merchantBaseUrl,
order_id: purch.orderId,
proposal_status: propStatus,
repurchase_proposal_id: prop.repurchaseProposalId,
timestamp: prop.timestamp,
contract_terms_raw: prop.download?.contractTermsRaw,
download_session_id: prop.downloadSessionId,
merchant_base_url: prop.merchantBaseUrl,
order_id: prop.orderId,
merchant_sig: prop.download?.contractData.merchantSig,
repurchase_proposal_id: purch.repurchaseProposalId,
download_session_id: purch.downloadSessionId,
timestamp_proposed: purch.timestamp,
});
});
@ -498,7 +490,6 @@ export async function exportBackup(
wallet_root_pub: bs.walletRootPub,
backup_providers: backupBackupProviders,
current_device_id: bs.deviceId,
proposals: backupProposals,
purchases: backupPurchases,
recoup_groups: backupRecoupGroups,
refresh_groups: backupRefreshGroups,

View File

@ -21,8 +21,8 @@ import {
BackupCoin,
BackupCoinSourceType,
BackupDenomSel,
BackupPayInfo,
BackupProposalStatus,
BackupPurchase,
BackupRefreshReason,
BackupRefundState,
BackupWgType,
@ -37,7 +37,6 @@ import {
WireInfo,
} from "@gnu-taler/taler-util";
import {
AbortStatus,
CoinRecord,
CoinSource,
CoinSourceType,
@ -48,28 +47,23 @@ import {
OperationStatus,
ProposalDownload,
ProposalStatus,
PurchasePayInfo,
RefreshCoinStatus,
RefreshSessionRecord,
RefundState,
ReserveBankInfo,
WithdrawalGroupStatus,
WalletContractData,
WalletRefundItem,
WalletStoresV1,
WgInfo,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
import {
checkDbInvariant,
checkLogicInvariant,
} from "../../util/invariants.js";
import { checkLogicInvariant } from "../../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
import { RetryInfo } from "../../util/retries.js";
import { makeCoinAvailable } from "../../wallet.js";
import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
import { getExchangeDetails } from "../exchanges.js";
import { makeEventId, TombstoneTag } from "../transactions.js";
import { provideBackupState } from "./state.js";
const logger = new Logger("operations/backup/import.ts");
@ -95,10 +89,10 @@ async function recoverPayCoinSelection(
denominations: typeof WalletStoresV1.denominations;
}>,
contractData: WalletContractData,
backupPurchase: BackupPurchase,
payInfo: BackupPayInfo,
): Promise<PayCoinSelection> {
const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
);
@ -316,7 +310,6 @@ export async function importBackup(
x.coinAvailability,
x.denominations,
x.purchases,
x.proposals,
x.refreshGroups,
x.backupProviders,
x.tips,
@ -560,113 +553,6 @@ export async function importBackup(
}
}
for (const backupProposal of backupBlob.proposals) {
const ts = makeEventId(
TombstoneTag.DeletePayment,
backupProposal.proposal_id,
);
if (tombstoneSet.has(ts)) {
continue;
}
const existingProposal = await tx.proposals.get(
backupProposal.proposal_id,
);
if (!existingProposal) {
let download: ProposalDownload | undefined;
let proposalStatus: ProposalStatus;
switch (backupProposal.proposal_status) {
case BackupProposalStatus.Proposed:
if (backupProposal.contract_terms_raw) {
proposalStatus = ProposalStatus.Proposed;
} else {
proposalStatus = ProposalStatus.Downloading;
}
break;
case BackupProposalStatus.Refused:
proposalStatus = ProposalStatus.Refused;
break;
case BackupProposalStatus.Repurchase:
proposalStatus = ProposalStatus.Repurchase;
break;
case BackupProposalStatus.PermanentlyFailed:
proposalStatus = ProposalStatus.PermanentlyFailed;
break;
}
if (backupProposal.contract_terms_raw) {
checkDbInvariant(!!backupProposal.merchant_sig);
const parsedContractTerms = codecForContractTerms().decode(
backupProposal.contract_terms_raw,
);
const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
const contractTermsHash =
cryptoComp.proposalIdToContractTermsHash[
backupProposal.proposal_id
];
let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) {
maxWireFee = Amounts.parseOrThrow(
parsedContractTerms.max_wire_fee,
);
} else {
maxWireFee = Amounts.getZero(amount.currency);
}
download = {
contractData: {
amount,
contractTermsHash: contractTermsHash,
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
merchantSig: backupProposal.merchant_sig,
orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund,
maxWireFee,
payDeadline: parsedContractTerms.pay_deadline,
refundDeadline: parsedContractTerms.refund_deadline,
wireFeeAmortization:
parsedContractTerms.wire_fee_amortization || 1,
allowedAuditors: parsedContractTerms.auditors.map((x) => ({
auditorBaseUrl: x.url,
auditorPub: x.auditor_pub,
})),
allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
exchangeBaseUrl: x.url,
exchangePub: x.master_pub,
})),
timestamp: parsedContractTerms.timestamp,
wireMethod: parsedContractTerms.wire_method,
wireInfoHash: parsedContractTerms.h_wire,
maxDepositFee: Amounts.parseOrThrow(
parsedContractTerms.max_fee,
),
merchant: parsedContractTerms.merchant,
products: parsedContractTerms.products,
summaryI18n: parsedContractTerms.summary_i18n,
deliveryDate: parsedContractTerms.delivery_date,
deliveryLocation: parsedContractTerms.delivery_location,
},
contractTermsRaw: backupProposal.contract_terms_raw,
};
}
await tx.proposals.put({
claimToken: backupProposal.claim_token,
merchantBaseUrl: backupProposal.merchant_base_url,
timestamp: backupProposal.timestamp,
orderId: backupProposal.order_id,
noncePriv: backupProposal.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
proposalId: backupProposal.proposal_id,
repurchaseProposalId: backupProposal.repurchase_proposal_id,
download,
proposalStatus,
// FIXME!
downloadSessionId: undefined,
});
}
}
for (const backupPurchase of backupBlob.purchases) {
const ts = makeEventId(
TombstoneTag.DeletePayment,
@ -678,6 +564,14 @@ export async function importBackup(
const existingPurchase = await tx.purchases.get(
backupPurchase.proposal_id,
);
let proposalStatus: ProposalStatus;
switch (backupPurchase.proposal_status) {
case BackupProposalStatus.Paid:
proposalStatus = ProposalStatus.Paid;
break;
default:
throw Error();
}
if (!existingPurchase) {
const refunds: { [refundKey: string]: WalletRefundItem } = {};
for (const backupRefund of backupPurchase.refunds) {
@ -721,25 +615,6 @@ export async function importBackup(
break;
}
}
let abortStatus: AbortStatus;
switch (backupPurchase.abort_status) {
case "abort-finished":
abortStatus = AbortStatus.AbortFinished;
break;
case "abort-refund":
abortStatus = AbortStatus.AbortRefund;
break;
case undefined:
abortStatus = AbortStatus.None;
break;
default:
logger.warn(
`got backup purchase abort_status ${j2s(
backupPurchase.abort_status,
)}`,
);
throw Error("not reachable");
}
const parsedContractTerms = codecForContractTerms().decode(
backupPurchase.contract_terms_raw,
);
@ -761,7 +636,7 @@ export async function importBackup(
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
merchantSig: backupPurchase.merchant_sig,
merchantSig: backupPurchase.merchant_sig!,
orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund,
@ -790,33 +665,46 @@ export async function importBackup(
},
contractTermsRaw: backupPurchase.contract_terms_raw,
};
let payInfo: PurchasePayInfo | undefined = undefined;
if (backupPurchase.pay_info) {
payInfo = {
coinDepositPermissions: undefined,
payCoinSelection: await recoverPayCoinSelection(
tx,
download.contractData,
backupPurchase.pay_info,
),
payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid,
totalPayCost: Amounts.parseOrThrow(
backupPurchase.pay_info.total_pay_cost,
),
};
}
await tx.purchases.put({
proposalId: backupPurchase.proposal_id,
noncePriv: backupPurchase.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
autoRefundDeadline: TalerProtocolTimestamp.never(),
refundAwaiting: undefined,
timestampAccept: backupPurchase.timestamp_accept,
timestampAccept: backupPurchase.timestamp_accepted,
timestampFirstSuccessfulPay:
backupPurchase.timestamp_first_successful_pay,
timestampLastRefundStatus: undefined,
merchantPaySig: backupPurchase.merchant_pay_sig,
lastSessionId: undefined,
abortStatus,
download,
paymentSubmitPending:
!backupPurchase.timestamp_first_successful_pay,
refundQueryRequested: false,
payCoinSelection: await recoverPayCoinSelection(
tx,
download.contractData,
backupPurchase,
),
coinDepositPermissions: undefined,
totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
refunds,
payCoinSelectionUid: backupPurchase.pay_coins_uid,
claimToken: backupPurchase.claim_token,
downloadSessionId: backupPurchase.download_session_id,
merchantBaseUrl: backupPurchase.merchant_base_url,
orderId: backupPurchase.order_id,
payInfo,
refundAmountAwaiting: undefined,
repurchaseProposalId: backupPurchase.repurchase_proposal_id,
status: proposalStatus,
timestamp: backupPurchase.timestamp_proposed,
});
}
}
@ -948,7 +836,6 @@ export async function importBackup(
await tx.depositGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeletePayment) {
await tx.purchases.delete(rest[0]);
await tx.proposals.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefreshGroup) {
await tx.refreshGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefund) {

View File

@ -96,7 +96,7 @@ import {
checkPaymentByProposalId,
confirmPay,
preparePayForUri,
} from "../pay.js";
} from "../pay-merchant.js";
import { exportBackup } from "./export.js";
import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
import { getWalletBackupState, provideBackupState } from "./state.js";
@ -193,15 +193,6 @@ async function computeBackupCryptoData(
eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
);
}
for (const prop of backupContent.proposals) {
const { h: contractTermsHash } = await cryptoApi.hashString({
str: canonicalJson(prop.contract_terms_raw),
});
const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
cryptoData.proposalIdToContractTermsHash[prop.proposal_id] =
contractTermsHash;
}
for (const purch of backupContent.purchases) {
const { h: contractTermsHash } = await cryptoApi.hashString({
str: canonicalJson(purch.contract_terms_raw),

View File

@ -17,38 +17,272 @@
/**
* Imports.
*/
import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util";
import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
import { TalerError, getErrorDetailFromException } from "../errors.js";
import {
AmountJson,
Amounts,
j2s,
Logger,
RefreshReason,
TalerErrorCode,
TalerErrorDetail,
TransactionType,
} from "@gnu-taler/taler-util";
import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js";
import { makeErrorDetail, TalerError } from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
RetryInfo,
} from "../util/retries.js";
import { createRefreshGroup } from "./refresh.js";
/**
* Run an operation and call the onOpError callback
* when there was an exception or operation error that must be reported.
* The cause will be re-thrown to the caller.
const logger = new Logger("operations/common.ts");
export interface CoinsSpendInfo {
coinPubs: string[];
contributions: AmountJson[];
refreshReason: RefreshReason;
/**
* Identifier for what the coin has been spent for.
*/
export async function guardOperationException<T>(
op: () => Promise<T>,
onOpError: (e: TalerErrorDetail) => Promise<void>,
): Promise<T> {
try {
return await op();
} catch (e: any) {
if (e instanceof CryptoApiStoppedError) {
throw e;
allocationId: string;
}
export async function makeCoinAvailable(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
denominations: typeof WalletStoresV1.denominations;
}>,
coinRecord: CoinRecord,
): Promise<void> {
checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
const existingCoin = await tx.coins.get(coinRecord.coinPub);
if (existingCoin) {
return;
}
if (
e instanceof TalerError &&
e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED)
) {
throw e;
const denom = await tx.denominations.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
]);
checkDbInvariant(!!denom);
const ageRestriction = coinRecord.maxAge;
let car = await tx.coinAvailability.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
ageRestriction,
]);
if (!car) {
car = {
maxAge: ageRestriction,
amountFrac: denom.amountFrac,
amountVal: denom.amountVal,
currency: denom.currency,
denomPubHash: denom.denomPubHash,
exchangeBaseUrl: denom.exchangeBaseUrl,
freshCoinCount: 0,
};
}
const opErr = getErrorDetailFromException(e);
await onOpError(opErr);
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
{
innerError: opErr,
},
car.freshCoinCount++;
await tx.coins.put(coinRecord);
await tx.coinAvailability.put(car);
}
export async function spendCoins(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
csi: CoinsSpendInfo,
): Promise<void> {
for (let i = 0; i < csi.coinPubs.length; i++) {
const coin = await tx.coins.get(csi.coinPubs[i]);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const coinAvailability = await tx.coinAvailability.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
coin.maxAge,
]);
checkDbInvariant(!!coinAvailability);
const contrib = csi.contributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation;
if (!alloc) {
continue;
}
if (alloc.id !== csi.allocationId) {
// FIXME: assign error code
throw Error("conflicting coin allocation (id)");
}
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
// FIXME: assign error code
throw Error("conflicting coin allocation (contrib)");
}
continue;
}
coin.status = CoinStatus.Dormant;
coin.allocation = {
id: csi.allocationId,
amount: Amounts.stringify(contrib),
};
const remaining = Amounts.sub(coin.currentAmount, contrib);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
coin.currentAmount = remaining.amount;
checkDbInvariant(!!coinAvailability);
if (coinAvailability.freshCoinCount === 0) {
throw Error(
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
);
}
coinAvailability.freshCoinCount--;
await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability);
}
const refreshCoinPubs = csi.coinPubs.map((x) => ({
coinPub: x,
}));
await ws.refreshOps.createRefreshGroup(
ws,
tx,
refreshCoinPubs,
RefreshReason.PayMerchant,
);
}
export async function storeOperationError(
ws: InternalWalletState,
pendingTaskId: string,
e: TalerErrorDetail,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
let retryRecord = await tx.operationRetries.get(pendingTaskId);
if (!retryRecord) {
retryRecord = {
id: pendingTaskId,
lastError: e,
retryInfo: RetryInfo.reset(),
};
} else {
retryRecord.lastError = e;
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
}
await tx.operationRetries.put(retryRecord);
});
}
export async function storeOperationPending(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
let retryRecord = await tx.operationRetries.get(pendingTaskId);
if (!retryRecord) {
retryRecord = {
id: pendingTaskId,
retryInfo: RetryInfo.reset(),
};
} else {
delete retryRecord.lastError;
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
}
await tx.operationRetries.put(retryRecord);
});
}
export async function runOperationWithErrorReporting(
ws: InternalWalletState,
opId: string,
f: () => Promise<OperationAttemptResult>,
): Promise<void> {
let maybeError: TalerErrorDetail | undefined;
try {
const resp = await f();
switch (resp.type) {
case OperationAttemptResultType.Error:
return await storeOperationError(ws, opId, resp.errorDetail);
case OperationAttemptResultType.Finished:
return await storeOperationFinished(ws, opId);
case OperationAttemptResultType.Pending:
return await storeOperationPending(ws, opId);
case OperationAttemptResultType.Longpoll:
break;
}
} catch (e) {
if (e instanceof TalerError) {
logger.warn("operation processed resulted in error");
logger.warn(`error was: ${j2s(e.errorDetail)}`);
maybeError = e.errorDetail;
return await storeOperationError(ws, opId, maybeError!);
} else if (e instanceof Error) {
// This is a bug, as we expect pending operations to always
// do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
// or return something.
logger.error(`Uncaught exception: ${e.message}`);
logger.error(`Stack: ${e.stack}`);
maybeError = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{
stack: e.stack,
},
`unexpected exception (message: ${e.message})`,
);
return await storeOperationError(ws, opId, maybeError);
} else {
logger.error("Uncaught exception, value is not even an error.");
maybeError = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{},
`unexpected exception (not even an error)`,
);
return await storeOperationError(ws, opId, maybeError);
}
}
}
export async function storeOperationFinished(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
await tx.operationRetries.delete(pendingTaskId);
});
}
export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
DeletePayment = "delete-payment",
DeleteTip = "delete-tip",
DeleteRefreshGroup = "delete-refresh-group",
DeleteDepositGroup = "delete-deposit-group",
DeleteRefund = "delete-refund",
DeletePeerPullDebit = "delete-peer-pull-debit",
DeletePeerPushDebit = "delete-peer-push-debit",
}
/**
* Create an event ID from the type and the primary key for the event.
*/
export function makeEventId(
type: TransactionType | TombstoneTag,
...args: string[]
): string {
return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
}

View File

@ -53,16 +53,15 @@ import {
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { OperationAttemptResult } from "../util/retries.js";
import { spendCoins } from "../wallet.js";
import { makeEventId, spendCoins } from "./common.js";
import { getExchangeDetails } from "./exchanges.js";
import {
extractContractData,
generateDepositPermissions,
getTotalPaymentCost,
selectPayCoinsNew,
} from "./pay.js";
} from "./pay-merchant.js";
import { getTotalRefreshCost } from "./refresh.js";
import { makeEventId } from "./transactions.js";
/**
* Logger.

View File

@ -40,7 +40,6 @@ import {
parsePaytoUri,
Recoup,
TalerErrorCode,
TalerErrorDetail,
TalerProtocolDuration,
TalerProtocolTimestamp,
URL,
@ -71,11 +70,9 @@ import {
import {
OperationAttemptResult,
OperationAttemptResultType,
RetryInfo,
runOperationHandlerForResult,
} from "../util/retries.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
import { guardOperationException } from "./common.js";
const logger = new Logger("exchanges.ts");

View File

@ -25,7 +25,7 @@ import {
LibtoolVersion,
} from "@gnu-taler/taler-util";
import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../index.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
const logger = new Logger("taler-wallet-core:merchants.ts");
@ -40,7 +40,7 @@ export async function getMerchantInfo(
return existingInfo;
}
const configUrl = new URL("config", canonBaseUrl);
const configUrl = new URL("config", canonBaseUrl);
const resp = await ws.http.get(configUrl.href);
const configResp = await readSuccessResponseJsonOrThrow(

View File

@ -73,9 +73,8 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { spendCoins } from "../wallet.js";
import { spendCoins, makeEventId } from "../operations/common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { makeEventId } from "./transactions.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/peer-to-peer.ts");

View File

@ -23,7 +23,6 @@
*/
import {
ProposalStatus,
AbortStatus,
WalletStoresV1,
BackupProviderStateTag,
RefreshCoinStatus,
@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { RetryTags } from "../util/retries.js";
import { Wallet } from "../wallet.js";
import { GlobalIDB } from "@gnu-taler/idb-bridge";
function getPendingCommon(
@ -184,38 +182,6 @@ async function gatherWithdrawalPending(
}
}
async function gatherProposalPending(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{
proposals: typeof WalletStoresV1.proposals;
operationRetries: typeof WalletStoresV1.operationRetries;
}>,
now: AbsoluteTime,
resp: PendingOperationsResponse,
): Promise<void> {
await tx.proposals.iter().forEachAsync(async (proposal) => {
if (proposal.proposalStatus == ProposalStatus.Proposed) {
// Nothing to do, user needs to choose.
} else if (proposal.proposalStatus == ProposalStatus.Downloading) {
const opId = RetryTags.forProposalClaim(proposal);
const retryRecord = await tx.operationRetries.get(opId);
const timestampDue =
retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now();
resp.pendingOperations.push({
type: PendingTaskType.ProposalDownload,
...getPendingCommon(ws, opId, timestampDue),
givesLifeness: true,
merchantBaseUrl: proposal.merchantBaseUrl,
orderId: proposal.orderId,
proposalId: proposal.proposalId,
proposalTimestamp: proposal.timestamp,
lastError: retryRecord?.lastError,
retryInfo: retryRecord?.retryInfo,
});
}
});
}
async function gatherDepositPending(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{
@ -287,43 +253,26 @@ async function gatherPurchasePending(
resp: PendingOperationsResponse,
): Promise<void> {
// FIXME: Only iter purchases with some "active" flag!
await tx.purchases.iter().forEachAsync(async (pr) => {
if (
pr.paymentSubmitPending &&
pr.abortStatus === AbortStatus.None &&
!pr.payFrozen
) {
const payOpId = RetryTags.forPay(pr);
const payRetryRecord = await tx.operationRetries.get(payOpId);
const timestampDue =
payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
resp.pendingOperations.push({
type: PendingTaskType.Pay,
...getPendingCommon(ws, payOpId, timestampDue),
givesLifeness: true,
isReplay: false,
proposalId: pr.proposalId,
retryInfo: payRetryRecord?.retryInfo,
lastError: payRetryRecord?.lastError,
});
}
if (pr.refundQueryRequested) {
const refundQueryOpId = RetryTags.forRefundQuery(pr);
const refundQueryRetryRecord = await tx.operationRetries.get(
refundQueryOpId,
const keyRange = GlobalIDB.KeyRange.bound(
OperationStatusRange.ACTIVE_START,
OperationStatusRange.ACTIVE_END,
);
await tx.purchases.indexes.byStatus
.iter(keyRange)
.forEachAsync(async (pr) => {
const opId = RetryTags.forPay(pr);
const retryRecord = await tx.operationRetries.get(opId);
const timestampDue =
refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
resp.pendingOperations.push({
type: PendingTaskType.RefundQuery,
...getPendingCommon(ws, refundQueryOpId, timestampDue),
type: PendingTaskType.Purchase,
...getPendingCommon(ws, opId, timestampDue),
givesLifeness: true,
statusStr: ProposalStatus[pr.status],
proposalId: pr.proposalId,
retryInfo: refundQueryRetryRecord?.retryInfo,
lastError: refundQueryRetryRecord?.lastError,
retryInfo: retryRecord?.retryInfo,
lastError: retryRecord?.lastError,
});
}
});
}
@ -404,7 +353,6 @@ export async function getPendingOperations(
x.refreshGroups,
x.coins,
x.withdrawalGroups,
x.proposals,
x.tips,
x.purchases,
x.planchets,
@ -419,7 +367,6 @@ export async function getPendingOperations(
await gatherExchangePending(ws, tx, now, resp);
await gatherRefreshPending(ws, tx, now, resp);
await gatherWithdrawalPending(ws, tx, now, resp);
await gatherProposalPending(ws, tx, now, resp);
await gatherDepositPending(ws, tx, now, resp);
await gatherTipPending(ws, tx, now, resp);
await gatherPurchasePending(ws, tx, now, resp);

View File

@ -27,16 +27,15 @@
import {
Amounts,
codecForRecoupConfirmation,
codecForReserveStatus,
encodeCrock,
getRandomBytes,
j2s,
Logger,
NotificationType,
RefreshReason,
TalerErrorDetail,
TalerProtocolTimestamp,
URL,
codecForReserveStatus,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
@ -44,8 +43,8 @@ import {
CoinStatus,
RecoupGroupRecord,
RefreshCoinSource,
WithdrawalGroupStatus,
WalletStoresV1,
WithdrawalGroupStatus,
WithdrawalRecordType,
WithdrawCoinSource,
} from "../db.js";
@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
OperationAttemptResult,
RetryInfo,
runOperationHandlerForResult,
} from "../util/retries.js";
import { guardOperationException } from "./common.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";

View File

@ -78,7 +78,7 @@ import {
OperationAttemptResult,
OperationAttemptResultType,
} from "../util/retries.js";
import { makeCoinAvailable } from "../wallet.js";
import { makeCoinAvailable } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
isWithdrawableDenom,

View File

@ -1,815 +0,0 @@
/*
This file is part of GNU Taler
(C) 2019-2019 Taler Systems S.A.
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/>
*/
/**
* Implementation of the refund operation.
*
* @author Florian Dold
*/
/**
* Imports.
*/
import {
AbortingCoin,
AbortRequest,
AbsoluteTime,
AmountJson,
Amounts,
ApplyRefundResponse,
codecForAbortResponse,
codecForMerchantOrderRefundPickupResponse,
codecForMerchantOrderStatusPaid,
CoinPublicKey,
Duration,
Logger,
MerchantCoinRefundFailureStatus,
MerchantCoinRefundStatus,
MerchantCoinRefundSuccessStatus,
NotificationType,
parseRefundUri,
PrepareRefundResult,
RefreshReason,
TalerErrorCode,
TalerProtocolTimestamp,
TransactionType,
URL,
} from "@gnu-taler/taler-util";
import {
AbortStatus,
CoinStatus,
DenominationRecord,
PurchaseRecord,
RefundReason,
RefundState,
WalletStoresV1,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadWriteAccess } from "../util/query.js";
import { OperationAttemptResult } from "../util/retries.js";
import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
import { makeEventId } from "./transactions.js";
const logger = new Logger("refund.ts");
export async function prepareRefund(
ws: InternalWalletState,
talerRefundUri: string,
): Promise<PrepareRefundResult> {
const parseResult = parseRefundUri(talerRefundUri);
logger.trace("preparing refund offer", parseResult);
if (!parseResult) {
throw Error("invalid refund URI");
}
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
parseResult.merchantBaseUrl,
parseResult.orderId,
]);
});
if (!purchase) {
throw Error(
`no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
);
}
const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
const summary = calculateRefundSummary(purchase);
const proposalId = purchase.proposalId;
const { contractData: c } = purchase.download;
return {
proposalId,
effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
gone: Amounts.stringify(summary.amountRefundGone),
granted: Amounts.stringify(summary.amountRefundGranted),
pending: summary.pendingAtExchange,
awaiting: Amounts.stringify(awaiting),
info: {
contractTermsHash: c.contractTermsHash,
merchant: c.merchant,
orderId: c.orderId,
products: c.products,
summary: c.summary,
fulfillmentMessage: c.fulfillmentMessage,
summary_i18n: c.summaryI18n,
fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
},
};
}
function getRefundKey(d: MerchantCoinRefundStatus): string {
return `${d.coin_pub}-${d.rtransaction_id}`;
}
async function applySuccessfulRefund(
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
denominations: typeof WalletStoresV1.denominations;
}>,
p: PurchaseRecord,
refreshCoinsMap: Record<string, { coinPub: string }>,
r: MerchantCoinRefundSuccessStatus,
): Promise<void> {
// FIXME: check signature before storing it as valid!
const refundKey = getRefundKey(r);
const coin = await tx.coins.get(r.coin_pub);
if (!coin) {
logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("inconsistent database");
}
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
const refundAmount = Amounts.parseOrThrow(r.refund_amount);
const refundFee = denom.fees.feeRefund;
coin.status = CoinStatus.Dormant;
coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
await tx.coins.put(coin);
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.fees.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Applied,
obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.fees.feeRefund,
totalRefreshCostBound,
coinPub: r.coin_pub,
rtransactionId: r.rtransaction_id,
};
}
async function storePendingRefund(
tx: GetReadWriteAccess<{
denominations: typeof WalletStoresV1.denominations;
coins: typeof WalletStoresV1.coins;
}>,
p: PurchaseRecord,
r: MerchantCoinRefundFailureStatus,
): Promise<void> {
const refundKey = getRefundKey(r);
const coin = await tx.coins.get(r.coin_pub);
if (!coin) {
logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("inconsistent database");
}
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.fees.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Pending,
obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.fees.feeRefund,
totalRefreshCostBound,
coinPub: r.coin_pub,
rtransactionId: r.rtransaction_id,
};
}
async function storeFailedRefund(
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
denominations: typeof WalletStoresV1.denominations;
}>,
p: PurchaseRecord,
refreshCoinsMap: Record<string, { coinPub: string }>,
r: MerchantCoinRefundFailureStatus,
): Promise<void> {
const refundKey = getRefundKey(r);
const coin = await tx.coins.get(r.coin_pub);
if (!coin) {
logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error("inconsistent database");
}
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.toArray();
const amountLeft = Amounts.sub(
Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
.amount,
denom.fees.feeRefund,
).amount;
const totalRefreshCostBound = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
);
p.refunds[refundKey] = {
type: RefundState.Failed,
obtainedTime: TalerProtocolTimestamp.now(),
executionTime: r.execution_time,
refundAmount: Amounts.parseOrThrow(r.refund_amount),
refundFee: denom.fees.feeRefund,
totalRefreshCostBound,
coinPub: r.coin_pub,
rtransactionId: r.rtransaction_id,
};
if (p.abortStatus === AbortStatus.AbortRefund) {
// Refund failed because the merchant didn't even try to deposit
// the coin yet, so we try to refresh.
if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
const coin = await tx.coins.get(r.coin_pub);
if (!coin) {
logger.warn("coin not found, can't apply refund");
return;
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
logger.warn("denomination for coin missing");
return;
}
let contrib: AmountJson | undefined;
for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
contrib = p.payCoinSelection.coinContributions[i];
}
}
if (contrib) {
coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
coin.currentAmount = Amounts.sub(
coin.currentAmount,
denom.fees.feeRefund,
).amount;
}
refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
await tx.coins.put(coin);
}
}
}
async function acceptRefunds(
ws: InternalWalletState,
proposalId: string,
refunds: MerchantCoinRefundStatus[],
reason: RefundReason,
): Promise<void> {
logger.trace("handling refunds", refunds);
const now = TalerProtocolTimestamp.now();
await ws.db
.mktx((x) => [
x.purchases,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
logger.error("purchase not found, not adding refunds");
return;
}
const refreshCoinsMap: Record<string, CoinPublicKey> = {};
for (const refundStatus of refunds) {
const refundKey = getRefundKey(refundStatus);
const existingRefundInfo = p.refunds[refundKey];
const isPermanentFailure =
refundStatus.type === "failure" &&
refundStatus.exchange_status >= 400 &&
refundStatus.exchange_status < 500;
// Already failed.
if (existingRefundInfo?.type === RefundState.Failed) {
continue;
}
// Already applied.
if (existingRefundInfo?.type === RefundState.Applied) {
continue;
}
// Still pending.
if (
refundStatus.type === "failure" &&
!isPermanentFailure &&
existingRefundInfo?.type === RefundState.Pending
) {
continue;
}
// Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
if (refundStatus.type === "success") {
await applySuccessfulRefund(tx, p, refreshCoinsMap, refundStatus);
} else if (isPermanentFailure) {
await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
} else {
await storePendingRefund(tx, p, refundStatus);
}
}
const refreshCoinsPubs = Object.values(refreshCoinsMap);
if (refreshCoinsPubs.length > 0) {
await createRefreshGroup(
ws,
tx,
refreshCoinsPubs,
RefreshReason.Refund,
);
}
// Are we done with querying yet, or do we need to do another round
// after a retry delay?
let queryDone = true;
if (
p.timestampFirstSuccessfulPay &&
p.autoRefundDeadline &&
AbsoluteTime.cmp(
AbsoluteTime.fromTimestamp(p.autoRefundDeadline),
AbsoluteTime.fromTimestamp(now),
) > 0
) {
queryDone = false;
}
let numPendingRefunds = 0;
for (const ri of Object.values(p.refunds)) {
switch (ri.type) {
case RefundState.Pending:
numPendingRefunds++;
break;
}
}
if (numPendingRefunds > 0) {
queryDone = false;
}
if (queryDone) {
p.timestampLastRefundStatus = now;
p.refundQueryRequested = false;
if (p.abortStatus === AbortStatus.AbortRefund) {
p.abortStatus = AbortStatus.AbortFinished;
}
logger.trace("refund query done");
} else {
// No error, but we need to try again!
p.timestampLastRefundStatus = now;
logger.trace("refund query not done");
}
await tx.purchases.put(p);
});
ws.notify({
type: NotificationType.RefundQueried,
});
}
function calculateRefundSummary(p: PurchaseRecord): RefundSummary {
let amountRefundGranted = Amounts.getZero(
p.download.contractData.amount.currency,
);
let amountRefundGone = Amounts.getZero(
p.download.contractData.amount.currency,
);
let pendingAtExchange = false;
Object.keys(p.refunds).forEach((rk) => {
const refund = p.refunds[rk];
if (refund.type === RefundState.Pending) {
pendingAtExchange = true;
}
if (
refund.type === RefundState.Applied ||
refund.type === RefundState.Pending
) {
amountRefundGranted = Amounts.add(
amountRefundGranted,
Amounts.sub(
refund.refundAmount,
refund.refundFee,
refund.totalRefreshCostBound,
).amount,
).amount;
} else {
amountRefundGone = Amounts.add(
amountRefundGone,
refund.refundAmount,
).amount;
}
});
return {
amountEffectivePaid: p.totalPayCost,
amountRefundGone,
amountRefundGranted,
pendingAtExchange,
};
}
/**
* Summary of the refund status of a purchase.
*/
export interface RefundSummary {
pendingAtExchange: boolean;
amountEffectivePaid: AmountJson;
amountRefundGranted: AmountJson;
amountRefundGone: AmountJson;
}
/**
* Accept a refund, return the contract hash for the contract
* that was involved in the refund.
*/
export async function applyRefund(
ws: InternalWalletState,
talerRefundUri: string,
): Promise<ApplyRefundResponse> {
const parseResult = parseRefundUri(talerRefundUri);
logger.trace("applying refund", parseResult);
if (!parseResult) {
throw Error("invalid refund URI");
}
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
parseResult.merchantBaseUrl,
parseResult.orderId,
]);
});
if (!purchase) {
throw Error(
`no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
);
}
return applyRefundFromPurchaseId(ws, purchase.proposalId);
}
export async function applyRefundFromPurchaseId(
ws: InternalWalletState,
proposalId: string,
): Promise<ApplyRefundResponse> {
logger.trace("applying refund for purchase", proposalId);
logger.info("processing purchase for refund");
const success = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
logger.error("no purchase found for refund URL");
return false;
}
p.refundQueryRequested = true;
await tx.purchases.put(p);
return true;
});
if (success) {
ws.notify({
type: NotificationType.RefundStarted,
});
await processPurchaseQueryRefund(ws, proposalId, {
forceNow: true,
waitForAutoRefund: false,
});
}
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!purchase) {
throw Error("purchase no longer exists");
}
const summary = calculateRefundSummary(purchase);
return {
contractTermsHash: purchase.download.contractData.contractTermsHash,
proposalId: purchase.proposalId,
transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund
amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
amountRefundGone: Amounts.stringify(summary.amountRefundGone),
amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
pendingAtExchange: summary.pendingAtExchange,
info: {
contractTermsHash: purchase.download.contractData.contractTermsHash,
merchant: purchase.download.contractData.merchant,
orderId: purchase.download.contractData.orderId,
products: purchase.download.contractData.products,
summary: purchase.download.contractData.summary,
fulfillmentMessage: purchase.download.contractData.fulfillmentMessage,
summary_i18n: purchase.download.contractData.summaryI18n,
fulfillmentMessage_i18n:
purchase.download.contractData.fulfillmentMessageI18n,
},
};
}
async function queryAndSaveAwaitingRefund(
ws: InternalWalletState,
purchase: PurchaseRecord,
waitForAutoRefund?: boolean,
): Promise<AmountJson> {
const requestUrl = new URL(
`orders/${purchase.download.contractData.orderId}`,
purchase.download.contractData.merchantBaseUrl,
);
requestUrl.searchParams.set(
"h_contract",
purchase.download.contractData.contractTermsHash,
);
// Long-poll for one second
if (waitForAutoRefund) {
requestUrl.searchParams.set("timeout_ms", "1000");
requestUrl.searchParams.set("await_refund_obtained", "yes");
logger.trace("making long-polling request for auto-refund");
}
const resp = await ws.http.get(requestUrl.href);
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
);
if (!orderStatus.refunded) {
// Wait for retry ...
return Amounts.getZero(purchase.totalPayCost.currency);
}
const refundAwaiting = Amounts.sub(
Amounts.parseOrThrow(orderStatus.refund_amount),
Amounts.parseOrThrow(orderStatus.refund_taken),
).amount;
if (
purchase.refundAwaiting === undefined ||
Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0
) {
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;
}
p.refundAwaiting = refundAwaiting;
await tx.purchases.put(p);
});
}
return refundAwaiting;
}
export async function processPurchaseQueryRefund(
ws: InternalWalletState,
proposalId: string,
options: {
forceNow?: boolean;
waitForAutoRefund?: boolean;
} = {},
): Promise<OperationAttemptResult> {
const waitForAutoRefund = options.waitForAutoRefund ?? false;
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!purchase) {
return OperationAttemptResult.finishedEmpty();
}
if (!purchase.refundQueryRequested) {
return OperationAttemptResult.finishedEmpty();
}
if (purchase.timestampFirstSuccessfulPay) {
if (
!purchase.autoRefundDeadline ||
!AbsoluteTime.isExpired(
AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
)
) {
const awaitingAmount = await queryAndSaveAwaitingRefund(
ws,
purchase,
waitForAutoRefund,
);
if (Amounts.isZero(awaitingAmount)) {
return OperationAttemptResult.finishedEmpty();
}
}
const requestUrl = new URL(
`orders/${purchase.download.contractData.orderId}/refund`,
purchase.download.contractData.merchantBaseUrl,
);
logger.trace(`making refund request to ${requestUrl.href}`);
const request = await ws.http.postJson(requestUrl.href, {
h_contract: purchase.download.contractData.contractTermsHash,
});
const refundResponse = await readSuccessResponseJsonOrThrow(
request,
codecForMerchantOrderRefundPickupResponse(),
);
await acceptRefunds(
ws,
proposalId,
refundResponse.refunds,
RefundReason.NormalRefund,
);
} else if (purchase.abortStatus === AbortStatus.AbortRefund) {
const requestUrl = new URL(
`orders/${purchase.download.contractData.orderId}/abort`,
purchase.download.contractData.merchantBaseUrl,
);
const abortingCoins: AbortingCoin[] = [];
await ws.db
.mktx((x) => [x.coins])
.runReadOnly(async (tx) => {
for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
const coinPub = purchase.payCoinSelection.coinPubs[i];
const coin = await tx.coins.get(coinPub);
checkDbInvariant(!!coin, "expected coin to be present");
abortingCoins.push({
coin_pub: coinPub,
contribution: Amounts.stringify(
purchase.payCoinSelection.coinContributions[i],
),
exchange_url: coin.exchangeBaseUrl,
});
}
});
const abortReq: AbortRequest = {
h_contract: purchase.download.contractData.contractTermsHash,
coins: abortingCoins,
};
logger.trace(`making order abort request to ${requestUrl.href}`);
const request = await ws.http.postJson(requestUrl.href, abortReq);
const abortResp = await readSuccessResponseJsonOrThrow(
request,
codecForAbortResponse(),
);
const refunds: MerchantCoinRefundStatus[] = [];
if (abortResp.refunds.length != abortingCoins.length) {
// FIXME: define error code!
throw Error("invalid order abort response");
}
for (let i = 0; i < abortResp.refunds.length; i++) {
const r = abortResp.refunds[i];
refunds.push({
...r,
coin_pub: purchase.payCoinSelection.coinPubs[i],
refund_amount: Amounts.stringify(
purchase.payCoinSelection.coinContributions[i],
),
rtransaction_id: 0,
execution_time: AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(
AbsoluteTime.fromTimestamp(
purchase.download.contractData.timestamp,
),
Duration.fromSpec({ seconds: 1 }),
),
),
});
}
await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
}
return OperationAttemptResult.finishedEmpty();
}
export async function abortFailedPayWithRefund(
ws: InternalWalletState,
proposalId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) {
throw Error("purchase not found");
}
if (purchase.timestampFirstSuccessfulPay) {
// No point in aborting it. We don't even report an error.
logger.warn(`tried to abort successful payment`);
return;
}
if (purchase.abortStatus !== AbortStatus.None) {
return;
}
purchase.refundQueryRequested = true;
purchase.paymentSubmitPending = false;
purchase.abortStatus = AbortStatus.AbortRefund;
await tx.purchases.put(purchase);
});
processPurchaseQueryRefund(ws, proposalId, {
forceNow: true,
}).catch((e) => {
logger.trace(`error during refund processing after abort pay: ${e}`);
});
}

View File

@ -40,9 +40,8 @@ import {
PreparePayResultType,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import { confirmPay, preparePayForUri } from "./pay.js";
import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js";
import { getBalances } from "./balance.js";
import { applyRefund } from "./refund.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
@ -471,6 +470,6 @@ export async function testPay(
});
checkLogicInvariant(!!purchase);
return {
payCoinSelection: purchase.payCoinSelection,
payCoinSelection: purchase.payInfo?.payCoinSelection!,
};
}

View File

@ -18,8 +18,8 @@
* Imports.
*/
import {
AgeRestriction,
AcceptTipResponse,
AgeRestriction,
Amounts,
BlindedDenominationSignature,
codecForMerchantTipResponseV2,
@ -56,9 +56,8 @@ import {
OperationAttemptResult,
OperationAttemptResultType,
} from "../util/retries.js";
import { makeCoinAvailable } from "../wallet.js";
import { makeCoinAvailable, makeEventId } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import { makeEventId } from "./transactions.js";
import {
getCandidateWithdrawalDenoms,
getExchangeWithdrawalInfo,

View File

@ -36,12 +36,12 @@ import {
WithdrawalType,
} from "@gnu-taler/taler-util";
import {
AbortStatus,
DepositGroupRecord,
ExchangeDetailsRecord,
OperationRetryRecord,
PeerPullPaymentIncomingRecord,
PeerPushPaymentInitiationRecord,
ProposalStatus,
PurchaseRecord,
RefundState,
TipRecord,
@ -50,10 +50,12 @@ import {
WithdrawalRecordType,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { RetryTags } from "../util/retries.js";
import { makeEventId, TombstoneTag } from "./common.js";
import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import { processPurchasePay } from "./pay.js";
import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js";
import { processRefreshGroup } from "./refresh.js";
import { processTip } from "./tip.js";
import {
@ -63,28 +65,6 @@ import {
const logger = new Logger("taler-wallet-core:transactions.ts");
export enum TombstoneTag {
DeleteWithdrawalGroup = "delete-withdrawal-group",
DeleteReserve = "delete-reserve",
DeletePayment = "delete-payment",
DeleteTip = "delete-tip",
DeleteRefreshGroup = "delete-refresh-group",
DeleteDepositGroup = "delete-deposit-group",
DeleteRefund = "delete-refund",
DeletePeerPullDebit = "delete-peer-pull-debit",
DeletePeerPushDebit = "delete-peer-push-debit",
}
/**
* Create an event ID from the type and the primary key for the event.
*/
export function makeEventId(
type: TransactionType | TombstoneTag,
...args: string[]
): string {
return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
}
function shouldSkipCurrency(
transactionsRequest: TransactionsRequest | undefined,
currency: string,
@ -219,29 +199,22 @@ export async function getTransactionById(
}),
);
const download = await expectProposalDownload(purchase);
const cleanRefunds = filteredRefunds.filter(
(x): x is WalletRefundItem => !!x,
);
const contractData = purchase.download.contractData;
const contractData = download.contractData;
const refunds = mergeRefundByExecutionTime(
cleanRefunds,
Amounts.getZero(contractData.amount.currency),
);
const payOpId = RetryTags.forPay(purchase);
const refundQueryOpId = RetryTags.forRefundQuery(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
const refundQueryRetryRecord = await tx.operationRetries.get(
refundQueryOpId,
);
const err =
payRetryRecord !== undefined
? payRetryRecord
: refundQueryRetryRecord;
return buildTransactionForPurchase(purchase, refunds, err);
return buildTransactionForPurchase(purchase, refunds, payRetryRecord);
});
} else if (type === TransactionType.Refresh) {
const refreshGroupId = rest[0];
@ -295,23 +268,14 @@ export async function getTransactionById(
),
);
if (t) throw Error("deleted");
const contractData = purchase.download.contractData;
const download = await expectProposalDownload(purchase);
const contractData = download.contractData;
const refunds = mergeRefundByExecutionTime(
[theRefund],
Amounts.getZero(contractData.amount.currency),
);
const refundQueryOpId = RetryTags.forRefundQuery(purchase);
const refundQueryRetryRecord = await tx.operationRetries.get(
refundQueryOpId,
);
return buildTransactionForRefund(
purchase,
refunds[0],
refundQueryRetryRecord,
);
return buildTransactionForRefund(purchase, refunds[0], undefined);
});
} else if (type === TransactionType.PeerPullDebit) {
const peerPullPaymentIncomingId = rest[0];
@ -606,12 +570,13 @@ function mergeRefundByExecutionTime(
return Array.from(refundByExecTime.values());
}
function buildTransactionForRefund(
async function buildTransactionForRefund(
purchaseRecord: PurchaseRecord,
refundInfo: MergedRefundInfo,
ort?: OperationRetryRecord,
): Transaction {
const contractData = purchaseRecord.download.contractData;
): Promise<Transaction> {
const download = await expectProposalDownload(purchaseRecord);
const contractData = download.contractData;
const info: OrderShortInfo = {
merchant: contractData.merchant,
@ -641,21 +606,22 @@ function buildTransactionForRefund(
amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
refundPending:
purchaseRecord.refundAwaiting === undefined
purchaseRecord.refundAmountAwaiting === undefined
? undefined
: Amounts.stringify(purchaseRecord.refundAwaiting),
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
pending: false,
frozen: false,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
function buildTransactionForPurchase(
async function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
refundsInfo: MergedRefundInfo[],
ort?: OperationRetryRecord,
): Transaction {
const contractData = purchaseRecord.download.contractData;
): Promise<Transaction> {
const download = await expectProposalDownload(purchaseRecord);
const contractData = download.contractData;
const zero = Amounts.getZero(contractData.amount.currency);
const info: OrderShortInfo = {
@ -696,31 +662,34 @@ function buildTransactionForPurchase(
),
}));
const timestamp = purchaseRecord.timestampAccept;
checkDbInvariant(!!timestamp);
checkDbInvariant(!!purchaseRecord.payInfo);
return {
type: TransactionType.Payment,
amountRaw: Amounts.stringify(contractData.amount),
amountEffective: Amounts.stringify(purchaseRecord.totalPayCost),
amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
totalRefundRaw: Amounts.stringify(totalRefund.raw),
totalRefundEffective: Amounts.stringify(totalRefund.effective),
refundPending:
purchaseRecord.refundAwaiting === undefined
purchaseRecord.refundAmountAwaiting === undefined
? undefined
: Amounts.stringify(purchaseRecord.refundAwaiting),
: Amounts.stringify(purchaseRecord.refundAmountAwaiting),
status: purchaseRecord.timestampFirstSuccessfulPay
? PaymentStatus.Paid
: PaymentStatus.Accepted,
pending:
!purchaseRecord.timestampFirstSuccessfulPay &&
purchaseRecord.abortStatus === AbortStatus.None,
pending: purchaseRecord.status === ProposalStatus.Paying,
refunds,
timestamp: purchaseRecord.timestampAccept,
timestamp,
transactionId: makeEventId(
TransactionType.Payment,
purchaseRecord.proposalId,
),
proposalId: purchaseRecord.proposalId,
info,
frozen: purchaseRecord.payFrozen ?? false,
frozen:
purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
@ -745,7 +714,6 @@ export async function getTransactions(
x.peerPullPaymentIncoming,
x.peerPushPaymentInitiations,
x.planchets,
x.proposals,
x.purchases,
x.recoupGroups,
x.tips,
@ -838,30 +806,33 @@ export async function getTransactions(
transactions.push(buildTransactionForDeposit(dg, retryRecord));
});
tx.purchases.iter().forEachAsync(async (pr) => {
tx.purchases.iter().forEachAsync(async (purchase) => {
const download = purchase.download;
if (!download) {
return;
}
if (!purchase.payInfo) {
return;
}
if (
shouldSkipCurrency(
transactionsRequest,
pr.download.contractData.amount.currency,
download.contractData.amount.currency,
)
) {
return;
}
const contractData = pr.download.contractData;
const contractData = download.contractData;
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
return;
}
const proposal = await tx.proposals.get(pr.proposalId);
if (!proposal) {
return;
}
const filteredRefunds = await Promise.all(
Object.values(pr.refunds).map(async (r) => {
Object.values(purchase.refunds).map(async (r) => {
const t = await tx.tombstones.get(
makeEventId(
TombstoneTag.DeleteRefund,
pr.proposalId,
purchase.proposalId,
`${r.executionTime.t_s}`,
),
);
@ -880,29 +851,16 @@ export async function getTransactions(
);
refunds.forEach(async (refundInfo) => {
const refundQueryOpId = RetryTags.forRefundQuery(pr);
const refundQueryRetryRecord = await tx.operationRetries.get(
refundQueryOpId,
);
transactions.push(
buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord),
await buildTransactionForRefund(purchase, refundInfo, undefined),
);
});
const payOpId = RetryTags.forPay(pr);
const refundQueryOpId = RetryTags.forRefundQuery(pr);
const payOpId = RetryTags.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
const refundQueryRetryRecord = await tx.operationRetries.get(
refundQueryOpId,
transactions.push(
await buildTransactionForPurchase(purchase, refunds, payRetryRecord),
);
const err =
payRetryRecord !== undefined
? payRetryRecord
: refundQueryRetryRecord;
transactions.push(buildTransactionForPurchase(pr, refunds, err));
});
tx.tips.iter().forEachAsync(async (tipRecord) => {
@ -1020,14 +978,9 @@ export async function deleteTransaction(
} else if (type === TransactionType.Payment) {
const proposalId = rest[0];
await ws.db
.mktx((x) => [x.proposals, x.purchases, x.tombstones])
.mktx((x) => [x.purchases, x.tombstones])
.runReadWrite(async (tx) => {
let found = false;
const proposal = await tx.proposals.get(proposalId);
if (proposal) {
found = true;
await tx.proposals.delete(proposalId);
}
const purchase = await tx.purchases.get(proposalId);
if (purchase) {
found = true;
@ -1083,7 +1036,7 @@ export async function deleteTransaction(
const executionTimeStr = rest[1];
await ws.db
.mktx((x) => [x.proposals, x.purchases, x.tombstones])
.mktx((x) => [x.purchases, x.tombstones])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (purchase) {

View File

@ -70,12 +70,11 @@ import {
DenomSelectionState,
ExchangeDetailsRecord,
ExchangeRecord,
OperationStatus,
PlanchetRecord,
WithdrawalGroupStatus,
WalletStoresV1,
WgInfo,
WithdrawalGroupRecord,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../db.js";
import {
@ -84,7 +83,10 @@ import {
TalerError,
} from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import {
makeCoinAvailable,
runOperationWithErrorReporting,
} from "../operations/common.js";
import { walletCoreDebugFlags } from "../util/debugFlags.js";
import {
HttpRequestLibrary,
@ -108,18 +110,16 @@ import {
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js";
import {
makeCoinAvailable,
runOperationWithErrorReporting,
makeEventId,
storeOperationError,
storeOperationPending,
} from "../wallet.js";
} from "./common.js";
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl,
} from "./exchanges.js";
import { makeEventId } from "./transactions.js";
/**
* Logger for this file.

View File

@ -34,11 +34,9 @@ import { RetryInfo } from "./util/retries.js";
export enum PendingTaskType {
ExchangeUpdate = "exchange-update",
ExchangeCheckRefresh = "exchange-check-refresh",
Pay = "pay",
ProposalDownload = "proposal-download",
Purchase = "purchase",
Refresh = "refresh",
Recoup = "recoup",
RefundQuery = "refund-query",
TipPickup = "tip-pickup",
Withdraw = "withdraw",
Deposit = "deposit",
@ -52,10 +50,8 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
(
| PendingExchangeUpdateTask
| PendingExchangeCheckRefreshTask
| PendingPayTask
| PendingProposalDownloadTask
| PendingPurchaseTask
| PendingRefreshTask
| PendingRefundQueryTask
| PendingTipPickupTask
| PendingWithdrawTask
| PendingRecoupTask
@ -109,19 +105,6 @@ export interface PendingRefreshTask {
retryInfo?: RetryInfo;
}
/**
* Status of downloading signed contract terms from a merchant.
*/
export interface PendingProposalDownloadTask {
type: PendingTaskType.ProposalDownload;
merchantBaseUrl: string;
proposalTimestamp: TalerProtocolTimestamp;
proposalId: string;
orderId: string;
lastError?: TalerErrorDetail;
retryInfo?: RetryInfo;
}
/**
* The wallet is picking up a tip that the user has accepted.
*/
@ -133,25 +116,16 @@ export interface PendingTipPickupTask {
}
/**
* The wallet is signing coins and then sending them to
* the merchant.
* A purchase needs to be processed (i.e. for download / payment / refund).
*/
export interface PendingPayTask {
type: PendingTaskType.Pay;
proposalId: string;
isReplay: boolean;
retryInfo?: RetryInfo;
lastError: TalerErrorDetail | undefined;
}
/**
* The wallet is querying the merchant about whether any refund
* permissions are available for a purchase.
*/
export interface PendingRefundQueryTask {
type: PendingTaskType.RefundQuery;
export interface PendingPurchaseTask {
type: PendingTaskType.Purchase;
proposalId: string;
retryInfo?: RetryInfo;
/**
* Status of the payment as string, used only for debugging.
*/
statusStr: string;
lastError: TalerErrorDetail | undefined;
}

View File

@ -30,7 +30,6 @@ import {
BackupProviderRecord,
DepositGroupRecord,
ExchangeRecord,
ProposalRecord,
PurchaseRecord,
RecoupGroupRecord,
RefreshGroupRecord,
@ -181,9 +180,6 @@ export namespace RetryTags {
export function forExchangeCheckRefresh(exch: ExchangeRecord): string {
return `${PendingTaskType.ExchangeCheckRefresh}:${exch.baseUrl}`;
}
export function forProposalClaim(pr: ProposalRecord): string {
return `${PendingTaskType.ProposalDownload}:${pr.proposalId}`;
}
export function forTipPickup(tipRecord: TipRecord): string {
return `${PendingTaskType.TipPickup}:${tipRecord.walletTipId}`;
}
@ -191,10 +187,7 @@ export namespace RetryTags {
return `${PendingTaskType.TipPickup}:${refreshGroupRecord.refreshGroupId}`;
}
export function forPay(purchaseRecord: PurchaseRecord): string {
return `${PendingTaskType.Pay}:${purchaseRecord.proposalId}`;
}
export function forRefundQuery(purchaseRecord: PurchaseRecord): string {
return `${PendingTaskType.RefundQuery}:${purchaseRecord.proposalId}`;
return `${PendingTaskType.Purchase}:${purchaseRecord.proposalId}`;
}
export function forRecoup(recoupRecord: RecoupGroupRecord): string {
return `${PendingTaskType.Recoup}:${recoupRecord.recoupGroupId}`;
@ -206,7 +199,7 @@ export namespace RetryTags {
return `${PendingTaskType.Backup}:${backupRecord.baseUrl}`;
}
export function byPaymentProposalId(proposalId: string): string {
return `${PendingTaskType.Pay}:${proposalId}`;
return `${PendingTaskType.Purchase}:${proposalId}`;
}
}

View File

@ -26,7 +26,6 @@ import {
AbsoluteTime,
AmountJson,
Amounts,
BalancesResponse,
codecForAbortPayWithRefundRequest,
codecForAcceptBankIntegratedWithdrawalRequest,
codecForAcceptExchangeTosRequest,
@ -35,6 +34,7 @@ import {
codecForAcceptPeerPushPaymentRequest,
codecForAcceptTipRequest,
codecForAddExchangeRequest,
codecForAddKnownBankAccounts,
codecForAny,
codecForApplyRefundFromPurchaseIdRequest,
codecForApplyRefundRequest,
@ -44,6 +44,7 @@ import {
codecForCreateDepositGroupRequest,
codecForDeleteTransactionRequest,
codecForForceRefreshRequest,
codecForForgetKnownBankAccounts,
codecForGetContractTermsDetails,
codecForGetExchangeTosRequest,
codecForGetExchangeWithdrawalInfo,
@ -81,6 +82,7 @@ import {
GetExchangeTosResult,
j2s,
KnownBankAccounts,
KnownBankAccountsInfo,
Logger,
ManualWithdrawalDetails,
NotificationType,
@ -89,9 +91,6 @@ import {
RefreshReason,
TalerErrorCode,
TalerErrorDetail,
KnownBankAccountsInfo,
codecForAddKnownBankAccounts,
codecForForgetKnownBankAccounts,
URL,
WalletCoreVersion,
WalletNotification,
@ -125,6 +124,7 @@ import {
MerchantOperations,
NotificationListener,
RecoupOperations,
RefreshOperations,
} from "./internal-wallet-state.js";
import { exportBackup } from "./operations/backup/export.js";
import {
@ -142,6 +142,11 @@ import {
} from "./operations/backup/index.js";
import { setWalletDeviceId } from "./operations/backup/state.js";
import { getBalances } from "./operations/balance.js";
import {
runOperationWithErrorReporting,
storeOperationError,
storeOperationPending,
} from "./operations/common.js";
import {
createDepositGroup,
getFeeForDeposit,
@ -162,12 +167,15 @@ import {
} from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js";
import {
abortFailedPayWithRefund,
applyRefund,
applyRefundFromPurchaseId,
confirmPay,
getContractTermsDetails,
preparePayForUri,
processDownloadProposal,
processPurchasePay,
} from "./operations/pay.js";
prepareRefund,
processPurchase,
} from "./operations/pay-merchant.js";
import {
acceptPeerPullPayment,
acceptPeerPushPayment,
@ -175,7 +183,7 @@ import {
checkPeerPushPayment,
initiatePeerRequestForPay,
initiatePeerToPeerPush,
} from "./operations/peer-to-peer.js";
} from "./operations/pay-peer.js";
import { getPendingOperations } from "./operations/pending.js";
import {
createRecoupGroup,
@ -187,13 +195,6 @@ import {
createRefreshGroup,
processRefreshGroup,
} from "./operations/refresh.js";
import {
abortFailedPayWithRefund,
applyRefund,
applyRefundFromPurchaseId,
prepareRefund,
processPurchaseQueryRefund,
} from "./operations/refund.js";
import {
runIntegrationTest,
testPay,
@ -213,13 +214,8 @@ import {
getWithdrawalDetailsForUri,
processWithdrawalGroup,
} from "./operations/withdraw.js";
import {
PendingOperationsResponse,
PendingTaskInfo,
PendingTaskType,
} from "./pending-types.js";
import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
import { assertUnreachable } from "./util/assertUnreachable.js";
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
import { createDenominationTimeline } from "./util/denominations.js";
import {
HttpRequestLibrary,
@ -306,18 +302,10 @@ async function callOperationHandler(
return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
forceNow,
});
case PendingTaskType.ProposalDownload:
return await processDownloadProposal(ws, pending.proposalId, {
forceNow,
});
case PendingTaskType.TipPickup:
return await processTip(ws, pending.tipId, { forceNow });
case PendingTaskType.Pay:
return await processPurchasePay(ws, pending.proposalId, { forceNow });
case PendingTaskType.RefundQuery:
return await processPurchaseQueryRefund(ws, pending.proposalId, {
forceNow,
});
case PendingTaskType.Purchase:
return await processPurchase(ws, pending.proposalId, { forceNow });
case PendingTaskType.Recoup:
return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
forceNow,
@ -337,111 +325,6 @@ async function callOperationHandler(
throw Error(`not reached ${pending.type}`);
}
export async function storeOperationError(
ws: InternalWalletState,
pendingTaskId: string,
e: TalerErrorDetail,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
let retryRecord = await tx.operationRetries.get(pendingTaskId);
if (!retryRecord) {
retryRecord = {
id: pendingTaskId,
lastError: e,
retryInfo: RetryInfo.reset(),
};
} else {
retryRecord.lastError = e;
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
}
await tx.operationRetries.put(retryRecord);
});
}
export async function storeOperationFinished(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
await tx.operationRetries.delete(pendingTaskId);
});
}
export async function storeOperationPending(
ws: InternalWalletState,
pendingTaskId: string,
): Promise<void> {
await ws.db
.mktx((x) => [x.operationRetries])
.runReadWrite(async (tx) => {
let retryRecord = await tx.operationRetries.get(pendingTaskId);
if (!retryRecord) {
retryRecord = {
id: pendingTaskId,
retryInfo: RetryInfo.reset(),
};
} else {
delete retryRecord.lastError;
retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
}
await tx.operationRetries.put(retryRecord);
});
}
export async function runOperationWithErrorReporting(
ws: InternalWalletState,
opId: string,
f: () => Promise<OperationAttemptResult>,
): Promise<void> {
let maybeError: TalerErrorDetail | undefined;
try {
const resp = await f();
switch (resp.type) {
case OperationAttemptResultType.Error:
return await storeOperationError(ws, opId, resp.errorDetail);
case OperationAttemptResultType.Finished:
return await storeOperationFinished(ws, opId);
case OperationAttemptResultType.Pending:
return await storeOperationPending(ws, opId);
case OperationAttemptResultType.Longpoll:
break;
}
} catch (e) {
if (e instanceof TalerError) {
logger.warn("operation processed resulted in error");
logger.warn(`error was: ${j2s(e.errorDetail)}`);
maybeError = e.errorDetail;
return await storeOperationError(ws, opId, maybeError!);
} else if (e instanceof Error) {
// This is a bug, as we expect pending operations to always
// do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
// or return something.
logger.error(`Uncaught exception: ${e.message}`);
logger.error(`Stack: ${e.stack}`);
maybeError = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{
stack: e.stack,
},
`unexpected exception (message: ${e.message})`,
);
return await storeOperationError(ws, opId, maybeError);
} else {
logger.error("Uncaught exception, value is not even an error.");
maybeError = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
{},
`unexpected exception (not even an error)`,
);
return await storeOperationError(ws, opId, maybeError);
}
}
}
/**
* Process pending operations.
*/
@ -857,120 +740,6 @@ async function getExchangeDetailedInfo(
};
}
export async function makeCoinAvailable(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
denominations: typeof WalletStoresV1.denominations;
}>,
coinRecord: CoinRecord,
): Promise<void> {
checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
const existingCoin = await tx.coins.get(coinRecord.coinPub);
if (existingCoin) {
return;
}
const denom = await tx.denominations.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
]);
checkDbInvariant(!!denom);
const ageRestriction = coinRecord.maxAge;
let car = await tx.coinAvailability.get([
coinRecord.exchangeBaseUrl,
coinRecord.denomPubHash,
ageRestriction,
]);
if (!car) {
car = {
maxAge: ageRestriction,
amountFrac: denom.amountFrac,
amountVal: denom.amountVal,
currency: denom.currency,
denomPubHash: denom.denomPubHash,
exchangeBaseUrl: denom.exchangeBaseUrl,
freshCoinCount: 0,
};
}
car.freshCoinCount++;
await tx.coins.put(coinRecord);
await tx.coinAvailability.put(car);
}
export interface CoinsSpendInfo {
coinPubs: string[];
contributions: AmountJson[];
refreshReason: RefreshReason;
/**
* Identifier for what the coin has been spent for.
*/
allocationId: string;
}
export async function spendCoins(
ws: InternalWalletState,
tx: GetReadWriteAccess<{
coins: typeof WalletStoresV1.coins;
coinAvailability: typeof WalletStoresV1.coinAvailability;
refreshGroups: typeof WalletStoresV1.refreshGroups;
denominations: typeof WalletStoresV1.denominations;
}>,
csi: CoinsSpendInfo,
): Promise<void> {
for (let i = 0; i < csi.coinPubs.length; i++) {
const coin = await tx.coins.get(csi.coinPubs[i]);
if (!coin) {
throw Error("coin allocated for payment doesn't exist anymore");
}
const coinAvailability = await tx.coinAvailability.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
coin.maxAge,
]);
checkDbInvariant(!!coinAvailability);
const contrib = csi.contributions[i];
if (coin.status !== CoinStatus.Fresh) {
const alloc = coin.allocation;
if (!alloc) {
continue;
}
if (alloc.id !== csi.allocationId) {
// FIXME: assign error code
throw Error("conflicting coin allocation (id)");
}
if (0 !== Amounts.cmp(alloc.amount, contrib)) {
// FIXME: assign error code
throw Error("conflicting coin allocation (contrib)");
}
continue;
}
coin.status = CoinStatus.Dormant;
coin.allocation = {
id: csi.allocationId,
amount: Amounts.stringify(contrib),
};
const remaining = Amounts.sub(coin.currentAmount, contrib);
if (remaining.saturated) {
throw Error("not enough remaining balance on coin for payment");
}
coin.currentAmount = remaining.amount;
checkDbInvariant(!!coinAvailability);
if (coinAvailability.freshCoinCount === 0) {
throw Error(
`invalid coin count ${coinAvailability.freshCoinCount} in DB`,
);
}
coinAvailability.freshCoinCount--;
await tx.coins.put(coin);
await tx.coinAvailability.put(coinAvailability);
}
const refreshCoinPubs = csi.coinPubs.map((x) => ({
coinPub: x,
}));
await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.PayMerchant);
}
async function setCoinSuspended(
ws: InternalWalletState,
coinPub: string,
@ -1649,6 +1418,10 @@ class InternalWalletStateImpl implements InternalWalletState {
getMerchantInfo,
};
refreshOps: RefreshOperations = {
createRefreshGroup,
};
// FIXME: Use an LRU cache here.
private denomCache: Record<string, DenominationInfo> = {};