wallet-core: put contract terms into separate object store

This commit is contained in:
Florian Dold 2022-10-09 02:23:06 +02:00
parent 8ac5080607
commit 19f3e6321d
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
6 changed files with 183 additions and 110 deletions

View File

@ -909,6 +909,8 @@ export interface BackupPurchase {
/**
* Signature on the contract terms.
*
* FIXME: Better name needed.
*/
merchant_sig?: string;

View File

@ -1085,18 +1085,16 @@ export enum PurchaseStatus {
Paid = OperationStatusRange.DORMANT_START + 5,
}
/**
* Partial information about the downloaded proposal.
* Only contains data that is relevant for indexing on the
* "purchases" object stores.
*/
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;
contractTermsHash: string;
fulfillmentUrl?: string;
currency: string;
contractTermsMerchantSig: string;
}
export interface PurchasePayInfo {
@ -1723,6 +1721,7 @@ export interface PeerPullPaymentInitiationRecord {
* Contract terms for the other party.
*
* FIXME: Nail down type!
* FIXME: Put in contractTerms store
*/
contractTerms: any;
}
@ -1819,6 +1818,18 @@ export interface CoinAvailabilityRecord {
freshCoinCount: number;
}
export interface ContractTermsRecord {
/**
* Contract terms hash.
*/
h: string;
/**
* Contract terms JSON.
*/
contractTermsRaw: any;
}
/**
* Schema definition for the IndexedDB
* wallet database.
@ -1937,13 +1948,8 @@ export const WalletStoresV1 = {
byStatus: describeIndex("byStatus", "purchaseStatus"),
byFulfillmentUrl: describeIndex(
"byFulfillmentUrl",
"download.contractData.fulfillmentUrl",
"download.fulfillmentUrl",
),
// FIXME: Deduplicate!
byMerchantUrlAndOrderId: describeIndex("byMerchantUrlAndOrderId", [
"download.contractData.merchantBaseUrl",
"download.contractData.orderId",
]),
byUrlAndOrderId: describeIndex("byUrlAndOrderId", [
"merchantBaseUrl",
"orderId",
@ -2088,6 +2094,13 @@ export const WalletStoresV1 = {
}),
{},
),
contractTerms: describeStore(
"contractTerms",
describeContents<ContractTermsRecord>({
keyPath: "h",
}),
{},
),
};
/**

View File

@ -88,6 +88,7 @@ export async function exportBackup(
x.exchanges,
x.exchangeDetails,
x.coins,
x.contractTerms,
x.denominations,
x.purchases,
x.refreshGroups,
@ -353,7 +354,7 @@ export async function exportBackup(
const purchaseProposalIdSet = new Set<string>();
await tx.purchases.iter().forEach((purch) => {
await tx.purchases.iter().forEachAsync(async (purch) => {
const refunds: BackupRefundItem[] = [];
purchaseProposalIdSet.add(purch.proposalId);
for (const refundKey of Object.keys(purch.refunds)) {
@ -418,8 +419,18 @@ export async function exportBackup(
};
}
let contractTermsRaw = undefined;
if (purch.download) {
const contractTermsRecord = await tx.contractTerms.get(
purch.download.contractTermsHash,
);
if (contractTermsRecord) {
contractTermsRaw = contractTermsRecord.contractTermsRaw;
}
}
backupPurchases.push({
contract_terms_raw: purch.download?.contractTermsRaw,
contract_terms_raw: contractTermsRaw,
auto_refund_deadline: purch.autoRefundDeadline,
merchant_pay_sig: purch.merchantPaySig,
pay_info: backupPayInfo,
@ -428,7 +439,7 @@ export async function exportBackup(
timestamp_accepted: purch.timestampAccept,
timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
nonce_priv: purch.noncePriv,
merchant_sig: purch.download?.contractData.merchantSig,
merchant_sig: purch.download?.contractTermsMerchantSig,
claim_token: purch.claimToken,
merchant_base_url: purch.merchantBaseUrl,
order_id: purch.orderId,

View File

@ -64,6 +64,7 @@ import { checkLogicInvariant } from "../../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
import { getExchangeDetails } from "../exchanges.js";
import { extractContractData } from "../pay-merchant.js";
import { provideBackupState } from "./state.js";
const logger = new Logger("operations/backup/import.ts");
@ -630,49 +631,25 @@ export async function importBackup(
maxWireFee = Amounts.getZero(amount.currency);
}
const download: ProposalDownload = {
contractData: {
amount,
contractTermsHash: contractTermsHash,
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
merchantSig: backupPurchase.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: backupPurchase.contract_terms_raw,
contractTermsHash,
contractTermsMerchantSig: backupPurchase.merchant_sig!,
currency: amount.currency,
fulfillmentUrl: backupPurchase.contract_terms_raw.fulfillment_url,
};
const contractData = extractContractData(
backupPurchase.contract_terms_raw,
contractTermsHash,
download.contractTermsMerchantSig,
);
let payInfo: PurchasePayInfo | undefined = undefined;
if (backupPurchase.pay_info) {
payInfo = {
coinDepositPermissions: undefined,
payCoinSelection: await recoverPayCoinSelection(
tx,
download.contractData,
contractData,
backupPurchase.pay_info,
),
payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid,

View File

@ -115,6 +115,7 @@ import {
throwUnexpectedRequestError,
} from "../util/http.js";
import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
@ -256,12 +257,34 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
* (Async since in the future this will query the DB.)
*/
export async function expectProposalDownload(
ws: InternalWalletState,
p: PurchaseRecord,
): Promise<ProposalDownload> {
): Promise<{
contractData: WalletContractData;
contractTermsRaw: any;
}> {
if (!p.download) {
throw Error("expected proposal to be downloaded");
}
return p.download;
const download = p.download;
return await ws.db
.mktx((x) => [x.contractTerms])
.runReadOnly(async (tx) => {
const contractTerms = await tx.contractTerms.get(
download.contractTermsHash,
);
if (!contractTerms) {
throw Error("contract terms not found");
}
return {
contractData: extractContractData(
contractTerms.contractTermsRaw,
download.contractTermsHash,
download.contractTermsMerchantSig,
),
contractTermsRaw: contractTerms.contractTermsRaw,
};
});
}
export function extractContractData(
@ -494,7 +517,7 @@ export async function processDownloadProposal(
logger.trace(`extracted contract data: ${j2s(contractData)}`);
await ws.db
.mktx((x) => [x.purchases])
.mktx((x) => [x.purchases, x.contractTerms])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(proposalId);
if (!p) {
@ -504,9 +527,15 @@ export async function processDownloadProposal(
return;
}
p.download = {
contractData,
contractTermsRaw: proposalResp.contract_terms,
contractTermsHash,
contractTermsMerchantSig: contractData.merchantSig,
currency: contractData.amount.currency,
fulfillmentUrl: contractData.fulfillmentUrl,
};
await tx.contractTerms.put({
h: contractTermsHash,
contractTermsRaw: proposalResp.contract_terms,
});
if (
fulfillmentUrl &&
(fulfillmentUrl.startsWith("http://") ||
@ -636,7 +665,7 @@ async function storeFirstPaySuccess(
): Promise<void> {
const now = AbsoluteTime.toTimestamp(AbsoluteTime.now());
await ws.db
.mktx((x) => [x.purchases])
.mktx((x) => [x.purchases, x.contractTerms])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
@ -655,7 +684,18 @@ async function storeFirstPaySuccess(
purchase.timestampFirstSuccessfulPay = now;
purchase.lastSessionId = sessionId;
purchase.merchantPaySig = paySig;
const protoAr = purchase.download!.contractData.autoRefund;
const dl = purchase.download;
checkDbInvariant(!!dl);
const contractTermsRecord = await tx.contractTerms.get(
dl.contractTermsHash,
);
checkDbInvariant(!!contractTermsRecord);
const contractData = extractContractData(
contractTermsRecord.contractTermsRaw,
dl.contractTermsHash,
dl.contractTermsMerchantSig,
);
const protoAr = contractData.autoRefund;
if (protoAr) {
const ar = Duration.fromTalerProtocolDuration(protoAr);
logger.info("auto_refund present");
@ -739,7 +779,7 @@ async function handleInsufficientFunds(
throw new TalerProtocolViolationError();
}
const { contractData } = proposal.download!;
const { contractData } = await expectProposalDownload(ws, proposal);
const prevPayCoins: PreviousPayCoins = [];
@ -1254,11 +1294,7 @@ export async function checkPaymentByProposalId(
throw Error("existing proposal is in wrong state");
}
}
const d = proposal.download;
if (!d) {
logger.error("bad proposal", proposal);
throw Error("proposal is in invalid state");
}
const d = await expectProposalDownload(ws, proposal);
const contractData = d.contractData;
const merchantSig = d.contractData.merchantSig;
if (!merchantSig) {
@ -1338,7 +1374,7 @@ export async function checkPaymentByProposalId(
// FIXME: This does not surface the original error
throw Error("submitting pay failed");
}
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
@ -1349,7 +1385,7 @@ export async function checkPaymentByProposalId(
proposalId,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
@ -1364,7 +1400,7 @@ export async function checkPaymentByProposalId(
purchase.purchaseStatus === PurchaseStatus.Paid ||
purchase.purchaseStatus === PurchaseStatus.QueryingRefund ||
purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund;
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
contractTerms: download.contractTermsRaw,
@ -1392,11 +1428,9 @@ export async function getContractTermsDetails(
throw Error(`proposal with id ${proposalId} not found`);
}
if (!proposal.download || !proposal.download.contractData) {
throw Error("proposal is in invalid state");
}
const d = await expectProposalDownload(ws, proposal);
return proposal.download.contractData;
return d.contractData;
}
/**
@ -1516,12 +1550,13 @@ export async function runPayForConfirmPay(
.runReadOnly(async (tx) => {
return tx.purchases.get(proposalId);
});
if (!purchase?.download) {
if (!purchase) {
throw Error("purchase record not available anymore");
}
const d = await expectProposalDownload(ws, purchase);
return {
type: ConfirmPayResultType.Done,
contractTerms: purchase.download.contractTermsRaw,
contractTerms: d.contractTermsRaw,
transactionId: makeEventId(TransactionType.Payment, proposalId),
};
}
@ -1599,7 +1634,7 @@ export async function confirmPay(
throw Error(`proposal with id ${proposalId} not found`);
}
const d = proposal.download;
const d = await expectProposalDownload(ws, proposal);
if (!d) {
throw Error("proposal is in invalid state");
}
@ -1810,7 +1845,7 @@ export async function processPurchasePay(
const payInfo = purchase.payInfo;
checkDbInvariant(!!payInfo, "payInfo");
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
if (!purchase.merchantPaySig) {
const payUrl = new URL(
`orders/${download.contractData.orderId}/pay`,
@ -2007,7 +2042,7 @@ export async function prepareRefund(
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
return tx.purchases.indexes.byUrlAndOrderId.get([
parseResult.merchantBaseUrl,
parseResult.orderId,
]);
@ -2020,10 +2055,10 @@ export async function prepareRefund(
}
const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
const summary = await calculateRefundSummary(purchase);
const summary = await calculateRefundSummary(ws, purchase);
const proposalId = purchase.proposalId;
const { contractData: c } = await expectProposalDownload(purchase);
const { contractData: c } = await expectProposalDownload(ws, purchase);
return {
proposalId,
@ -2380,9 +2415,10 @@ async function acceptRefunds(
}
async function calculateRefundSummary(
ws: InternalWalletState,
p: PurchaseRecord,
): Promise<RefundSummary> {
const download = await expectProposalDownload(p);
const download = await expectProposalDownload(ws, p);
let amountRefundGranted = Amounts.getZero(
download.contractData.amount.currency,
);
@ -2456,7 +2492,7 @@ export async function applyRefund(
const purchase = await ws.db
.mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
return tx.purchases.indexes.byUrlAndOrderId.get([
parseResult.merchantBaseUrl,
parseResult.orderId,
]);
@ -2513,8 +2549,8 @@ export async function applyRefundFromPurchaseId(
throw Error("purchase no longer exists");
}
const summary = await calculateRefundSummary(purchase);
const download = await expectProposalDownload(purchase);
const summary = await calculateRefundSummary(ws, purchase);
const download = await expectProposalDownload(ws, purchase);
return {
contractTermsHash: download.contractData.contractTermsHash,
@ -2542,7 +2578,7 @@ async function queryAndSaveAwaitingRefund(
purchase: PurchaseRecord,
waitForAutoRefund?: boolean,
): Promise<AmountJson> {
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
const requestUrl = new URL(
`orders/${download.contractData.orderId}`,
download.contractData.merchantBaseUrl,
@ -2621,7 +2657,7 @@ export async function processPurchaseQueryRefund(
return OperationAttemptResult.finishedEmpty();
}
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
if (purchase.timestampFirstSuccessfulPay) {
if (

View File

@ -48,6 +48,7 @@ import {
WalletRefundItem,
WithdrawalGroupRecord,
WithdrawalRecordType,
WalletContractData,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
@ -55,7 +56,11 @@ import { RetryTags } from "../util/retries.js";
import { makeEventId, TombstoneTag } from "./common.js";
import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js";
import {
expectProposalDownload,
extractContractData,
processPurchasePay,
} from "./pay-merchant.js";
import { processRefreshGroup } from "./refresh.js";
import { processTip } from "./tip.js";
import {
@ -199,7 +204,7 @@ export async function getTransactionById(
}),
);
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
const cleanRefunds = filteredRefunds.filter(
(x): x is WalletRefundItem => !!x,
@ -214,7 +219,12 @@ export async function getTransactionById(
const payOpId = RetryTags.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
return buildTransactionForPurchase(purchase, refunds, payRetryRecord);
return buildTransactionForPurchase(
purchase,
contractData,
refunds,
payRetryRecord,
);
});
} else if (type === TransactionType.Refresh) {
const refreshGroupId = rest[0];
@ -268,14 +278,19 @@ export async function getTransactionById(
),
);
if (t) throw Error("deleted");
const download = await expectProposalDownload(purchase);
const download = await expectProposalDownload(ws, purchase);
const contractData = download.contractData;
const refunds = mergeRefundByExecutionTime(
[theRefund],
Amounts.getZero(contractData.amount.currency),
);
return buildTransactionForRefund(purchase, refunds[0], undefined);
return buildTransactionForRefund(
purchase,
contractData,
refunds[0],
undefined,
);
});
} else if (type === TransactionType.PeerPullDebit) {
const peerPullPaymentIncomingId = rest[0];
@ -572,12 +587,10 @@ function mergeRefundByExecutionTime(
async function buildTransactionForRefund(
purchaseRecord: PurchaseRecord,
contractData: WalletContractData,
refundInfo: MergedRefundInfo,
ort?: OperationRetryRecord,
): Promise<Transaction> {
const download = await expectProposalDownload(purchaseRecord);
const contractData = download.contractData;
const info: OrderShortInfo = {
merchant: contractData.merchant,
orderId: contractData.orderId,
@ -617,11 +630,10 @@ async function buildTransactionForRefund(
async function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
contractData: WalletContractData,
refundsInfo: MergedRefundInfo[],
ort?: OperationRetryRecord,
): Promise<Transaction> {
const download = await expectProposalDownload(purchaseRecord);
const contractData = download.contractData;
const zero = Amounts.getZero(contractData.amount.currency);
const info: OrderShortInfo = {
@ -689,7 +701,8 @@ async function buildTransactionForPurchase(
proposalId: purchaseRecord.proposalId,
info,
frozen:
purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ?? false,
purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ??
false,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
@ -715,6 +728,7 @@ export async function getTransactions(
x.peerPushPaymentInitiations,
x.planchets,
x.purchases,
x.contractTerms,
x.recoupGroups,
x.tips,
x.tombstones,
@ -814,18 +828,28 @@ export async function getTransactions(
if (!purchase.payInfo) {
return;
}
if (shouldSkipCurrency(transactionsRequest, download.currency)) {
return;
}
const contractTermsRecord = await tx.contractTerms.get(
download.contractTermsHash,
);
if (!contractTermsRecord) {
return;
}
if (
shouldSkipCurrency(
transactionsRequest,
download.contractData.amount.currency,
)
shouldSkipSearch(transactionsRequest, [
contractTermsRecord?.contractTermsRaw?.summary || "",
])
) {
return;
}
const contractData = download.contractData;
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
return;
}
const contractData = extractContractData(
contractTermsRecord?.contractTermsRaw,
download.contractTermsHash,
download.contractTermsMerchantSig,
);
const filteredRefunds = await Promise.all(
Object.values(purchase.refunds).map(async (r) => {
@ -847,19 +871,29 @@ export async function getTransactions(
const refunds = mergeRefundByExecutionTime(
cleanRefunds,
Amounts.getZero(contractData.amount.currency),
Amounts.getZero(download.currency),
);
refunds.forEach(async (refundInfo) => {
transactions.push(
await buildTransactionForRefund(purchase, refundInfo, undefined),
await buildTransactionForRefund(
purchase,
contractData,
refundInfo,
undefined,
),
);
});
const payOpId = RetryTags.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
transactions.push(
await buildTransactionForPurchase(purchase, refunds, payRetryRecord),
await buildTransactionForPurchase(
purchase,
contractData,
refunds,
payRetryRecord,
),
);
});