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

104
.vscode/settings.json vendored
View File

@ -1,53 +1,59 @@
// Place your settings in this file to overwrite default and user settings. // Place your settings in this file to overwrite default and user settings.
{ {
// Use latest language servicesu // Use latest language servicesu
"typescript.tsdk": "./node_modules/typescript/lib", "typescript.tsdk": "./node_modules/typescript/lib",
// Defines space handling after a comma delimiter // Defines space handling after a comma delimiter
"typescript.format.insertSpaceAfterCommaDelimiter": true, "typescript.format.insertSpaceAfterCommaDelimiter": true,
// Defines space handling after a semicolon in a for statement // Defines space handling after a semicolon in a for statement
"typescript.format.insertSpaceAfterSemicolonInForStatements": true, "typescript.format.insertSpaceAfterSemicolonInForStatements": true,
// Defines space handling after a binary operator // Defines space handling after a binary operator
"typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true, "typescript.format.insertSpaceBeforeAndAfterBinaryOperators": true,
// Defines space handling after keywords in control flow statement // Defines space handling after keywords in control flow statement
"typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true, "typescript.format.insertSpaceAfterKeywordsInControlFlowStatements": true,
// Defines space handling after function keyword for anonymous functions // Defines space handling after function keyword for anonymous functions
"typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true, "typescript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": true,
// Defines space handling after opening and before closing non empty parenthesis // Defines space handling after opening and before closing non empty parenthesis
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis": false,
// Defines space handling after opening and before closing non empty brackets // Defines space handling after opening and before closing non empty brackets
"typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false, "typescript.format.insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets": false,
// Defines whether an open brace is put onto a new line for functions or not // Defines whether an open brace is put onto a new line for functions or not
"typescript.format.placeOpenBraceOnNewLineForFunctions": false, "typescript.format.placeOpenBraceOnNewLineForFunctions": false,
// Defines whether an open brace is put onto a new line for control blocks or not // Defines whether an open brace is put onto a new line for control blocks or not
"typescript.format.placeOpenBraceOnNewLineForControlBlocks": false, "typescript.format.placeOpenBraceOnNewLineForControlBlocks": false,
// Files hidden in the explorer "typescript.preferences.autoImportFileExcludePatterns": [
"files.exclude": { "index.js",
// include the defaults from VS Code "index.*.js",
"**/.git": true, "index.ts",
"**/.DS_Store": true, "index.*.ts"
// exclude .js and .js.map files, when in a TypeScript project ],
"**/*.js": { // Files hidden in the explorer
"when": "$(basename).ts" "files.exclude": {
}, // include the defaults from VS Code
"**/*?.js": { "**/.git": true,
"when": "$(basename).tsx" "**/.DS_Store": true,
}, // exclude .js and .js.map files, when in a TypeScript project
"**/*.js.map": true "**/*.js": {
"when": "$(basename).ts"
}, },
"editor.wrappingIndent": "same", "**/*?.js": {
"editor.tabSize": 2, "when": "$(basename).tsx"
"search.exclude": {
"dist": true,
"prebuilt": true,
"src/i18n/*.po": true,
"vendor": true
}, },
"search.collapseResults": "auto", "**/*.js.map": true
"files.associations": { },
"api-extractor.json": "jsonc" "editor.wrappingIndent": "same",
}, "editor.tabSize": 2,
"typescript.preferences.importModuleSpecifierEnding": "js", "search.exclude": {
"typescript.preferences.importModuleSpecifier": "project-relative", "dist": true,
"javascript.preferences.importModuleSpecifier": "project-relative", "prebuilt": true,
"javascript.preferences.importModuleSpecifierEnding": "js" "src/i18n/*.po": true,
} "vendor": true
},
"search.collapseResults": "auto",
"files.associations": {
"api-extractor.json": "jsonc"
},
"typescript.preferences.importModuleSpecifierEnding": "js",
"typescript.preferences.importModuleSpecifier": "project-relative",
"javascript.preferences.importModuleSpecifier": "project-relative",
"javascript.preferences.importModuleSpecifierEnding": "js"
}

View File

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

View File

@ -180,15 +180,6 @@ export interface WalletBackupContentV1 {
*/ */
tips: BackupTip[]; 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. * Accepted purchases.
* *
@ -838,29 +829,10 @@ export type BackupRefundItem =
| BackupRefundPendingItem | BackupRefundPendingItem
| BackupRefundAppliedItem; | BackupRefundAppliedItem;
export interface BackupPurchase { /**
/** * Data we store when the payment was accepted.
* Proposal ID for this purchase. Uniquely identifies the */
* purchase and the proposal. export interface BackupPayInfo {
*/
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;
pay_coins: { pay_coins: {
/** /**
* Public keys of the coins that were selected. * 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. * We might show adjustments to this later, but currently we don't do so.
*/ */
total_pay_cost: BackupAmountString; 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 * Timestamp of the first time that sending a payment to the merchant
@ -902,11 +931,13 @@ export interface BackupPurchase {
*/ */
merchant_pay_sig: string | undefined; merchant_pay_sig: string | undefined;
timestamp_proposed: TalerProtocolTimestamp;
/** /**
* When was the purchase made? * When was the purchase made?
* Refers to the time that the user accepted. * Refers to the time that the user accepted.
*/ */
timestamp_accept: TalerProtocolTimestamp; timestamp_accepted: TalerProtocolTimestamp | undefined;
/** /**
* Pending refunds for the purchase. A refund is pending * Pending refunds for the purchase. A refund is pending
@ -914,11 +945,6 @@ export interface BackupPurchase {
*/ */
refunds: BackupRefundItem[]; refunds: BackupRefundItem[];
/**
* Abort status of the payment.
*/
abort_status?: "abort-refund" | "abort-finished";
/** /**
* Continue querying the refund status until this deadline has expired. * 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. * Downloaded proposal was detected as a re-purchase.
*/ */
Repurchase = "repurchase", Repurchase = "repurchase",
}
/** Paid = "paid",
* 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;
} }
export interface BackupRecovery { export interface BackupRecovery {

View File

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

View File

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

View File

@ -34,11 +34,7 @@ import {
TalerErrorCode, TalerErrorCode,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { TalerError } from "./errors.js"; import { TalerError } from "./errors.js";
import { import { HttpRequestLibrary, readSuccessResponseJsonOrThrow } from "./util/http.js";
HttpRequestLibrary,
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
} from "./index.browser.js";
const logger = new Logger("bank-api-client.ts"); 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 const WALLET_DB_MINOR_VERSION = 2;
export namespace OperationStatusRange { export enum OperationStatusRange {
export const ACTIVE_START = 10; ACTIVE_START = 10,
export const ACTIVE_END = 29; ACTIVE_END = 29,
export const DORMANT_START = 50; DORMANT_START = 50,
export const DORMANT_END = 69; DORMANT_END = 69,
} }
/** /**
@ -741,93 +741,6 @@ export interface CoinAllocation {
amount: AmountString; 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. * Status of a tip we got from a merchant.
*/ */
@ -1113,23 +1026,132 @@ export interface WalletContractData {
deliveryLocation: Location | undefined; deliveryLocation: Location | undefined;
} }
export enum AbortStatus { export enum ProposalStatus {
None = "none", /**
AbortRefund = "abort-refund", * Not downloaded yet.
AbortFinished = "abort-finished", */
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 * Record that stores status information about one purchase, starting from when
* the customer accepts a proposal. Includes refund status if applicable. * the customer accepts a proposal. Includes refund status if applicable.
*
* FIXME: Should have a single "status" field.
*/ */
export interface PurchaseRecord { export interface PurchaseRecord {
/** /**
* Proposal ID for this purchase. Uniquely identifies the * Proposal ID for this purchase. Uniquely identifies the
* purchase and the proposal. * purchase and the proposal.
* Assigned by the wallet.
*/ */
proposalId: string; 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. * Private key for the nonce.
*/ */
@ -1146,18 +1168,9 @@ export interface PurchaseRecord {
* FIXME: Move this into another object store, * FIXME: Move this into another object store,
* to improve read/write perf on purchases. * to improve read/write perf on purchases.
*/ */
download: ProposalDownload; download: ProposalDownload | undefined;
/** payInfo: PurchasePayInfo | 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;
/** /**
* Pending removals from pay coin selection. * Pending removals from pay coin selection.
@ -1169,8 +1182,6 @@ export interface PurchaseRecord {
*/ */
pendingRemovedCoinPubs?: string[]; pendingRemovedCoinPubs?: string[];
totalPayCost: AmountJson;
/** /**
* Timestamp of the first time that sending a payment to the merchant * Timestamp of the first time that sending a payment to the merchant
* for this purchase was successful. * for this purchase was successful.
@ -1181,11 +1192,16 @@ export interface PurchaseRecord {
merchantPaySig: string | undefined; merchantPaySig: string | undefined;
/**
* When was the purchase record created?
*/
timestamp: TalerProtocolTimestamp;
/** /**
* When was the purchase made? * When was the purchase made?
* Refers to the time that the user accepted. * Refers to the time that the user accepted.
*/ */
timestampAccept: TalerProtocolTimestamp; timestampAccept: TalerProtocolTimestamp | undefined;
/** /**
* Pending refunds for the purchase. A refund is pending * Pending refunds for the purchase. A refund is pending
@ -1206,18 +1222,6 @@ export interface PurchaseRecord {
*/ */
lastSessionId: string | undefined; 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. * 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 * How much merchant has refund to be taken but the wallet
* did not picked up yet * did not picked up yet
*/ */
refundAwaiting: AmountJson | undefined; refundAmountAwaiting: 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;
} }
export const WALLET_BACKUP_STATE_KEY = "walletBackupState"; 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: describeStore(
"refreshGroups", "refreshGroups",
describeContents<RefreshGroupRecord>({ describeContents<RefreshGroupRecord>({
@ -1953,14 +1936,20 @@ export const WalletStoresV1 = {
"purchases", "purchases",
describeContents<PurchaseRecord>({ keyPath: "proposalId" }), describeContents<PurchaseRecord>({ keyPath: "proposalId" }),
{ {
byStatus: describeIndex("byStatus", "operationStatus"),
byFulfillmentUrl: describeIndex( byFulfillmentUrl: describeIndex(
"byFulfillmentUrl", "byFulfillmentUrl",
"download.contractData.fulfillmentUrl", "download.contractData.fulfillmentUrl",
), ),
// FIXME: Deduplicate!
byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [ byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
"download.contractData.merchantBaseUrl", "download.contractData.merchantBaseUrl",
"download.contractData.orderId", "download.contractData.orderId",
]), ]),
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
"merchantBaseUrl",
"orderId",
]),
}, },
), ),
tips: describeStore( tips: describeStore(

View File

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

View File

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

View File

@ -37,6 +37,9 @@ import {
TalerProtocolTimestamp, TalerProtocolTimestamp,
CancellationToken, CancellationToken,
DenominationInfo, DenominationInfo,
RefreshGroupId,
CoinPublicKey,
RefreshReason,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js"; import { CryptoDispatcher } from "./crypto/workers/cryptoDispatcher.js";
import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js"; import { TalerCryptoInterface } from "./crypto/cryptoImplementation.js";
@ -74,6 +77,20 @@ export interface MerchantOperations {
): Promise<MerchantInfo>; ): 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. * Interface for exchange-related operations.
*/ */
@ -172,6 +189,7 @@ export interface InternalWalletState {
exchangeOps: ExchangeOperations; exchangeOps: ExchangeOperations;
recoupOps: RecoupOperations; recoupOps: RecoupOperations;
merchantOps: MerchantOperations; merchantOps: MerchantOperations;
refreshOps: RefreshOperations;
getDenomInfo( getDenomInfo(
ws: InternalWalletState, ws: InternalWalletState,

View File

@ -37,7 +37,7 @@ import {
BackupExchangeDetails, BackupExchangeDetails,
BackupExchangeWireFee, BackupExchangeWireFee,
BackupOperationStatus, BackupOperationStatus,
BackupProposal, BackupPayInfo,
BackupProposalStatus, BackupProposalStatus,
BackupPurchase, BackupPurchase,
BackupRecoupGroup, BackupRecoupGroup,
@ -62,11 +62,9 @@ import {
WalletBackupContentV1, WalletBackupContentV1,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
AbortStatus,
CoinSourceType, CoinSourceType,
CoinStatus, CoinStatus,
DenominationRecord, DenominationRecord,
OperationStatus,
ProposalStatus, ProposalStatus,
RefreshCoinStatus, RefreshCoinStatus,
RefundState, RefundState,
@ -92,7 +90,6 @@ export async function exportBackup(
x.coins, x.coins,
x.denominations, x.denominations,
x.purchases, x.purchases,
x.proposals,
x.refreshGroups, x.refreshGroups,
x.backupProviders, x.backupProviders,
x.tips, x.tips,
@ -109,7 +106,6 @@ export async function exportBackup(
[url: string]: BackupDenomination[]; [url: string]: BackupDenomination[];
} = {}; } = {};
const backupPurchases: BackupPurchase[] = []; const backupPurchases: BackupPurchase[] = [];
const backupProposals: BackupProposal[] = [];
const backupRefreshGroups: BackupRefreshGroup[] = []; const backupRefreshGroups: BackupRefreshGroup[] = [];
const backupBackupProviders: BackupBackupProvider[] = []; const backupBackupProviders: BackupBackupProvider[] = [];
const backupTips: BackupTip[] = []; 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; let propStatus: BackupProposalStatus;
switch (prop.proposalStatus) { switch (purch.status) {
case ProposalStatus.Accepted: case ProposalStatus.Paid:
propStatus = BackupProposalStatus.Paid;
return; return;
case ProposalStatus.Downloading: case ProposalStatus.DownloadingProposal:
case ProposalStatus.Proposed: case ProposalStatus.Proposed:
propStatus = BackupProposalStatus.Proposed; propStatus = BackupProposalStatus.Proposed;
break; break;
case ProposalStatus.PermanentlyFailed: case ProposalStatus.ProposalDownloadFailed:
propStatus = BackupProposalStatus.PermanentlyFailed; propStatus = BackupProposalStatus.PermanentlyFailed;
break; break;
case ProposalStatus.Refused: case ProposalStatus.ProposalRefused:
propStatus = BackupProposalStatus.Refused; propStatus = BackupProposalStatus.Refused;
break; break;
case ProposalStatus.Repurchase: case ProposalStatus.RepurchaseDetected:
propStatus = BackupProposalStatus.Repurchase; propStatus = BackupProposalStatus.Repurchase;
break; break;
default:
throw Error();
} }
backupProposals.push({
claim_token: prop.claimToken, const payInfo = purch.payInfo;
nonce_priv: prop.noncePriv, let backupPayInfo: BackupPayInfo | undefined = undefined;
proposal_id: prop.noncePriv, 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, proposal_status: propStatus,
repurchase_proposal_id: prop.repurchaseProposalId, repurchase_proposal_id: purch.repurchaseProposalId,
timestamp: prop.timestamp, download_session_id: purch.downloadSessionId,
contract_terms_raw: prop.download?.contractTermsRaw, timestamp_proposed: purch.timestamp,
download_session_id: prop.downloadSessionId,
merchant_base_url: prop.merchantBaseUrl,
order_id: prop.orderId,
merchant_sig: prop.download?.contractData.merchantSig,
}); });
}); });
@ -498,7 +490,6 @@ export async function exportBackup(
wallet_root_pub: bs.walletRootPub, wallet_root_pub: bs.walletRootPub,
backup_providers: backupBackupProviders, backup_providers: backupBackupProviders,
current_device_id: bs.deviceId, current_device_id: bs.deviceId,
proposals: backupProposals,
purchases: backupPurchases, purchases: backupPurchases,
recoup_groups: backupRecoupGroups, recoup_groups: backupRecoupGroups,
refresh_groups: backupRefreshGroups, refresh_groups: backupRefreshGroups,

View File

@ -21,8 +21,8 @@ import {
BackupCoin, BackupCoin,
BackupCoinSourceType, BackupCoinSourceType,
BackupDenomSel, BackupDenomSel,
BackupPayInfo,
BackupProposalStatus, BackupProposalStatus,
BackupPurchase,
BackupRefreshReason, BackupRefreshReason,
BackupRefundState, BackupRefundState,
BackupWgType, BackupWgType,
@ -37,7 +37,6 @@ import {
WireInfo, WireInfo,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { import {
AbortStatus,
CoinRecord, CoinRecord,
CoinSource, CoinSource,
CoinSourceType, CoinSourceType,
@ -48,28 +47,23 @@ import {
OperationStatus, OperationStatus,
ProposalDownload, ProposalDownload,
ProposalStatus, ProposalStatus,
PurchasePayInfo,
RefreshCoinStatus, RefreshCoinStatus,
RefreshSessionRecord, RefreshSessionRecord,
RefundState, RefundState,
ReserveBankInfo,
WithdrawalGroupStatus,
WalletContractData, WalletContractData,
WalletRefundItem, WalletRefundItem,
WalletStoresV1, WalletStoresV1,
WgInfo, WgInfo,
WithdrawalGroupStatus,
WithdrawalRecordType, WithdrawalRecordType,
} from "../../db.js"; } from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js"; import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js"; import { assertUnreachable } from "../../util/assertUnreachable.js";
import { import { checkLogicInvariant } from "../../util/invariants.js";
checkDbInvariant,
checkLogicInvariant,
} from "../../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js"; import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
import { RetryInfo } from "../../util/retries.js"; import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
import { makeCoinAvailable } from "../../wallet.js";
import { getExchangeDetails } from "../exchanges.js"; import { getExchangeDetails } from "../exchanges.js";
import { makeEventId, TombstoneTag } from "../transactions.js";
import { provideBackupState } from "./state.js"; import { provideBackupState } from "./state.js";
const logger = new Logger("operations/backup/import.ts"); const logger = new Logger("operations/backup/import.ts");
@ -95,10 +89,10 @@ async function recoverPayCoinSelection(
denominations: typeof WalletStoresV1.denominations; denominations: typeof WalletStoresV1.denominations;
}>, }>,
contractData: WalletContractData, contractData: WalletContractData,
backupPurchase: BackupPurchase, payInfo: BackupPayInfo,
): Promise<PayCoinSelection> { ): Promise<PayCoinSelection> {
const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub); const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) => const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
Amounts.parseOrThrow(x.contribution), Amounts.parseOrThrow(x.contribution),
); );
@ -316,7 +310,6 @@ export async function importBackup(
x.coinAvailability, x.coinAvailability,
x.denominations, x.denominations,
x.purchases, x.purchases,
x.proposals,
x.refreshGroups, x.refreshGroups,
x.backupProviders, x.backupProviders,
x.tips, 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) { for (const backupPurchase of backupBlob.purchases) {
const ts = makeEventId( const ts = makeEventId(
TombstoneTag.DeletePayment, TombstoneTag.DeletePayment,
@ -678,6 +564,14 @@ export async function importBackup(
const existingPurchase = await tx.purchases.get( const existingPurchase = await tx.purchases.get(
backupPurchase.proposal_id, backupPurchase.proposal_id,
); );
let proposalStatus: ProposalStatus;
switch (backupPurchase.proposal_status) {
case BackupProposalStatus.Paid:
proposalStatus = ProposalStatus.Paid;
break;
default:
throw Error();
}
if (!existingPurchase) { if (!existingPurchase) {
const refunds: { [refundKey: string]: WalletRefundItem } = {}; const refunds: { [refundKey: string]: WalletRefundItem } = {};
for (const backupRefund of backupPurchase.refunds) { for (const backupRefund of backupPurchase.refunds) {
@ -721,25 +615,6 @@ export async function importBackup(
break; 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( const parsedContractTerms = codecForContractTerms().decode(
backupPurchase.contract_terms_raw, backupPurchase.contract_terms_raw,
); );
@ -761,7 +636,7 @@ export async function importBackup(
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url, merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub, merchantPub: parsedContractTerms.merchant_pub,
merchantSig: backupPurchase.merchant_sig, merchantSig: backupPurchase.merchant_sig!,
orderId: parsedContractTerms.order_id, orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary, summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund, autoRefund: parsedContractTerms.auto_refund,
@ -790,33 +665,46 @@ export async function importBackup(
}, },
contractTermsRaw: backupPurchase.contract_terms_raw, 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({ await tx.purchases.put({
proposalId: backupPurchase.proposal_id, proposalId: backupPurchase.proposal_id,
noncePriv: backupPurchase.nonce_priv, noncePriv: backupPurchase.nonce_priv,
noncePub: noncePub:
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv], cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
autoRefundDeadline: TalerProtocolTimestamp.never(), autoRefundDeadline: TalerProtocolTimestamp.never(),
refundAwaiting: undefined, timestampAccept: backupPurchase.timestamp_accepted,
timestampAccept: backupPurchase.timestamp_accept,
timestampFirstSuccessfulPay: timestampFirstSuccessfulPay:
backupPurchase.timestamp_first_successful_pay, backupPurchase.timestamp_first_successful_pay,
timestampLastRefundStatus: undefined, timestampLastRefundStatus: undefined,
merchantPaySig: backupPurchase.merchant_pay_sig, merchantPaySig: backupPurchase.merchant_pay_sig,
lastSessionId: undefined, lastSessionId: undefined,
abortStatus,
download, 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, 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]); await tx.depositGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeletePayment) { } else if (type === TombstoneTag.DeletePayment) {
await tx.purchases.delete(rest[0]); await tx.purchases.delete(rest[0]);
await tx.proposals.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefreshGroup) { } else if (type === TombstoneTag.DeleteRefreshGroup) {
await tx.refreshGroups.delete(rest[0]); await tx.refreshGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefund) { } else if (type === TombstoneTag.DeleteRefund) {

View File

@ -96,7 +96,7 @@ import {
checkPaymentByProposalId, checkPaymentByProposalId,
confirmPay, confirmPay,
preparePayForUri, preparePayForUri,
} from "../pay.js"; } from "../pay-merchant.js";
import { exportBackup } from "./export.js"; import { exportBackup } from "./export.js";
import { BackupCryptoPrecomputedData, importBackup } from "./import.js"; import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
import { getWalletBackupState, provideBackupState } from "./state.js"; import { getWalletBackupState, provideBackupState } from "./state.js";
@ -193,15 +193,6 @@ async function computeBackupCryptoData(
eddsaGetPublic(decodeCrock(backupWg.reserve_priv)), 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) { for (const purch of backupContent.purchases) {
const { h: contractTermsHash } = await cryptoApi.hashString({ const { h: contractTermsHash } = await cryptoApi.hashString({
str: canonicalJson(purch.contract_terms_raw), str: canonicalJson(purch.contract_terms_raw),

View File

@ -17,38 +17,272 @@
/** /**
* Imports. * Imports.
*/ */
import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util"; import {
import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js"; AmountJson,
import { TalerError, getErrorDetailFromException } from "../errors.js"; 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";
/** const logger = new Logger("operations/common.ts");
* Run an operation and call the onOpError callback
* when there was an exception or operation error that must be reported. export interface CoinsSpendInfo {
* The cause will be re-thrown to the caller. coinPubs: string[];
*/ contributions: AmountJson[];
export async function guardOperationException<T>( refreshReason: RefreshReason;
op: () => Promise<T>, /**
onOpError: (e: TalerErrorDetail) => Promise<void>, * Identifier for what the coin has been spent for.
): Promise<T> { */
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;
}
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 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 { try {
return await op(); const resp = await f();
} catch (e: any) { switch (resp.type) {
if (e instanceof CryptoApiStoppedError) { case OperationAttemptResultType.Error:
throw e; 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;
} }
if ( } catch (e) {
e instanceof TalerError && if (e instanceof TalerError) {
e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED) logger.warn("operation processed resulted in error");
) { logger.warn(`error was: ${j2s(e.errorDetail)}`);
throw e; 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);
} }
const opErr = getErrorDetailFromException(e);
await onOpError(opErr);
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
{
innerError: opErr,
},
);
} }
} }
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 { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js"; import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { OperationAttemptResult } from "../util/retries.js"; import { OperationAttemptResult } from "../util/retries.js";
import { spendCoins } from "../wallet.js"; import { makeEventId, spendCoins } from "./common.js";
import { getExchangeDetails } from "./exchanges.js"; import { getExchangeDetails } from "./exchanges.js";
import { import {
extractContractData, extractContractData,
generateDepositPermissions, generateDepositPermissions,
getTotalPaymentCost, getTotalPaymentCost,
selectPayCoinsNew, selectPayCoinsNew,
} from "./pay.js"; } from "./pay-merchant.js";
import { getTotalRefreshCost } from "./refresh.js"; import { getTotalRefreshCost } from "./refresh.js";
import { makeEventId } from "./transactions.js";
/** /**
* Logger. * Logger.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,7 +78,7 @@ import {
OperationAttemptResult, OperationAttemptResult,
OperationAttemptResultType, OperationAttemptResultType,
} from "../util/retries.js"; } from "../util/retries.js";
import { makeCoinAvailable } from "../wallet.js"; import { makeCoinAvailable } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { import {
isWithdrawableDenom, 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, PreparePayResultType,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js"; 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 { getBalances } from "./balance.js";
import { applyRefund } from "./refund.js";
import { checkLogicInvariant } from "../util/invariants.js"; import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js"; import { acceptWithdrawalFromUri } from "./withdraw.js";
@ -471,6 +470,6 @@ export async function testPay(
}); });
checkLogicInvariant(!!purchase); checkLogicInvariant(!!purchase);
return { return {
payCoinSelection: purchase.payCoinSelection, payCoinSelection: purchase.payInfo?.payCoinSelection!,
}; };
} }

View File

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

View File

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

View File

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

View File

@ -34,11 +34,9 @@ import { RetryInfo } from "./util/retries.js";
export enum PendingTaskType { export enum PendingTaskType {
ExchangeUpdate = "exchange-update", ExchangeUpdate = "exchange-update",
ExchangeCheckRefresh = "exchange-check-refresh", ExchangeCheckRefresh = "exchange-check-refresh",
Pay = "pay", Purchase = "purchase",
ProposalDownload = "proposal-download",
Refresh = "refresh", Refresh = "refresh",
Recoup = "recoup", Recoup = "recoup",
RefundQuery = "refund-query",
TipPickup = "tip-pickup", TipPickup = "tip-pickup",
Withdraw = "withdraw", Withdraw = "withdraw",
Deposit = "deposit", Deposit = "deposit",
@ -52,10 +50,8 @@ export type PendingTaskInfo = PendingTaskInfoCommon &
( (
| PendingExchangeUpdateTask | PendingExchangeUpdateTask
| PendingExchangeCheckRefreshTask | PendingExchangeCheckRefreshTask
| PendingPayTask | PendingPurchaseTask
| PendingProposalDownloadTask
| PendingRefreshTask | PendingRefreshTask
| PendingRefundQueryTask
| PendingTipPickupTask | PendingTipPickupTask
| PendingWithdrawTask | PendingWithdrawTask
| PendingRecoupTask | PendingRecoupTask
@ -109,19 +105,6 @@ export interface PendingRefreshTask {
retryInfo?: RetryInfo; 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. * 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 * A purchase needs to be processed (i.e. for download / payment / refund).
* the merchant.
*/ */
export interface PendingPayTask { export interface PendingPurchaseTask {
type: PendingTaskType.Pay; type: PendingTaskType.Purchase;
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;
proposalId: string; proposalId: string;
retryInfo?: RetryInfo; retryInfo?: RetryInfo;
/**
* Status of the payment as string, used only for debugging.
*/
statusStr: string;
lastError: TalerErrorDetail | undefined; lastError: TalerErrorDetail | undefined;
} }

View File

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

View File

@ -26,7 +26,6 @@ import {
AbsoluteTime, AbsoluteTime,
AmountJson, AmountJson,
Amounts, Amounts,
BalancesResponse,
codecForAbortPayWithRefundRequest, codecForAbortPayWithRefundRequest,
codecForAcceptBankIntegratedWithdrawalRequest, codecForAcceptBankIntegratedWithdrawalRequest,
codecForAcceptExchangeTosRequest, codecForAcceptExchangeTosRequest,
@ -35,6 +34,7 @@ import {
codecForAcceptPeerPushPaymentRequest, codecForAcceptPeerPushPaymentRequest,
codecForAcceptTipRequest, codecForAcceptTipRequest,
codecForAddExchangeRequest, codecForAddExchangeRequest,
codecForAddKnownBankAccounts,
codecForAny, codecForAny,
codecForApplyRefundFromPurchaseIdRequest, codecForApplyRefundFromPurchaseIdRequest,
codecForApplyRefundRequest, codecForApplyRefundRequest,
@ -44,6 +44,7 @@ import {
codecForCreateDepositGroupRequest, codecForCreateDepositGroupRequest,
codecForDeleteTransactionRequest, codecForDeleteTransactionRequest,
codecForForceRefreshRequest, codecForForceRefreshRequest,
codecForForgetKnownBankAccounts,
codecForGetContractTermsDetails, codecForGetContractTermsDetails,
codecForGetExchangeTosRequest, codecForGetExchangeTosRequest,
codecForGetExchangeWithdrawalInfo, codecForGetExchangeWithdrawalInfo,
@ -81,6 +82,7 @@ import {
GetExchangeTosResult, GetExchangeTosResult,
j2s, j2s,
KnownBankAccounts, KnownBankAccounts,
KnownBankAccountsInfo,
Logger, Logger,
ManualWithdrawalDetails, ManualWithdrawalDetails,
NotificationType, NotificationType,
@ -89,9 +91,6 @@ import {
RefreshReason, RefreshReason,
TalerErrorCode, TalerErrorCode,
TalerErrorDetail, TalerErrorDetail,
KnownBankAccountsInfo,
codecForAddKnownBankAccounts,
codecForForgetKnownBankAccounts,
URL, URL,
WalletCoreVersion, WalletCoreVersion,
WalletNotification, WalletNotification,
@ -125,6 +124,7 @@ import {
MerchantOperations, MerchantOperations,
NotificationListener, NotificationListener,
RecoupOperations, RecoupOperations,
RefreshOperations,
} from "./internal-wallet-state.js"; } from "./internal-wallet-state.js";
import { exportBackup } from "./operations/backup/export.js"; import { exportBackup } from "./operations/backup/export.js";
import { import {
@ -142,6 +142,11 @@ import {
} from "./operations/backup/index.js"; } from "./operations/backup/index.js";
import { setWalletDeviceId } from "./operations/backup/state.js"; import { setWalletDeviceId } from "./operations/backup/state.js";
import { getBalances } from "./operations/balance.js"; import { getBalances } from "./operations/balance.js";
import {
runOperationWithErrorReporting,
storeOperationError,
storeOperationPending,
} from "./operations/common.js";
import { import {
createDepositGroup, createDepositGroup,
getFeeForDeposit, getFeeForDeposit,
@ -162,12 +167,15 @@ import {
} from "./operations/exchanges.js"; } from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js"; import { getMerchantInfo } from "./operations/merchants.js";
import { import {
abortFailedPayWithRefund,
applyRefund,
applyRefundFromPurchaseId,
confirmPay, confirmPay,
getContractTermsDetails, getContractTermsDetails,
preparePayForUri, preparePayForUri,
processDownloadProposal, prepareRefund,
processPurchasePay, processPurchase,
} from "./operations/pay.js"; } from "./operations/pay-merchant.js";
import { import {
acceptPeerPullPayment, acceptPeerPullPayment,
acceptPeerPushPayment, acceptPeerPushPayment,
@ -175,7 +183,7 @@ import {
checkPeerPushPayment, checkPeerPushPayment,
initiatePeerRequestForPay, initiatePeerRequestForPay,
initiatePeerToPeerPush, initiatePeerToPeerPush,
} from "./operations/peer-to-peer.js"; } from "./operations/pay-peer.js";
import { getPendingOperations } from "./operations/pending.js"; import { getPendingOperations } from "./operations/pending.js";
import { import {
createRecoupGroup, createRecoupGroup,
@ -187,13 +195,6 @@ import {
createRefreshGroup, createRefreshGroup,
processRefreshGroup, processRefreshGroup,
} from "./operations/refresh.js"; } from "./operations/refresh.js";
import {
abortFailedPayWithRefund,
applyRefund,
applyRefundFromPurchaseId,
prepareRefund,
processPurchaseQueryRefund,
} from "./operations/refund.js";
import { import {
runIntegrationTest, runIntegrationTest,
testPay, testPay,
@ -213,13 +214,8 @@ import {
getWithdrawalDetailsForUri, getWithdrawalDetailsForUri,
processWithdrawalGroup, processWithdrawalGroup,
} from "./operations/withdraw.js"; } from "./operations/withdraw.js";
import { import { PendingTaskInfo, PendingTaskType } from "./pending-types.js";
PendingOperationsResponse,
PendingTaskInfo,
PendingTaskType,
} from "./pending-types.js";
import { assertUnreachable } from "./util/assertUnreachable.js"; import { assertUnreachable } from "./util/assertUnreachable.js";
import { AsyncOpMemoMap, AsyncOpMemoSingle } from "./util/asyncMemo.js";
import { createDenominationTimeline } from "./util/denominations.js"; import { createDenominationTimeline } from "./util/denominations.js";
import { import {
HttpRequestLibrary, HttpRequestLibrary,
@ -306,18 +302,10 @@ async function callOperationHandler(
return await processWithdrawalGroup(ws, pending.withdrawalGroupId, { return await processWithdrawalGroup(ws, pending.withdrawalGroupId, {
forceNow, forceNow,
}); });
case PendingTaskType.ProposalDownload:
return await processDownloadProposal(ws, pending.proposalId, {
forceNow,
});
case PendingTaskType.TipPickup: case PendingTaskType.TipPickup:
return await processTip(ws, pending.tipId, { forceNow }); return await processTip(ws, pending.tipId, { forceNow });
case PendingTaskType.Pay: case PendingTaskType.Purchase:
return await processPurchasePay(ws, pending.proposalId, { forceNow }); return await processPurchase(ws, pending.proposalId, { forceNow });
case PendingTaskType.RefundQuery:
return await processPurchaseQueryRefund(ws, pending.proposalId, {
forceNow,
});
case PendingTaskType.Recoup: case PendingTaskType.Recoup:
return await processRecoupGroupHandler(ws, pending.recoupGroupId, { return await processRecoupGroupHandler(ws, pending.recoupGroupId, {
forceNow, forceNow,
@ -337,111 +325,6 @@ async function callOperationHandler(
throw Error(`not reached ${pending.type}`); 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. * 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( async function setCoinSuspended(
ws: InternalWalletState, ws: InternalWalletState,
coinPub: string, coinPub: string,
@ -1649,6 +1418,10 @@ class InternalWalletStateImpl implements InternalWalletState {
getMerchantInfo, getMerchantInfo,
}; };
refreshOps: RefreshOperations = {
createRefreshGroup,
};
// FIXME: Use an LRU cache here. // FIXME: Use an LRU cache here.
private denomCache: Record<string, DenominationInfo> = {}; private denomCache: Record<string, DenominationInfo> = {};