commit d9af4dd3f745d17d171d292d21da8ebf7e44f5f7
parent a7259d73a9d1087245ce5afcd0a351a7f7a8ad09
Author: Iván Ávalos <avalos@disroot.org>
Date: Thu, 27 Mar 2025 17:06:31 +0100
WIP: logic/v1 refinements
Diffstat:
5 files changed, 126 insertions(+), 57 deletions(-)
diff --git a/packages/taler-util/src/contract-terms.ts b/packages/taler-util/src/contract-terms.ts
@@ -14,6 +14,7 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
+import { assertUnreachable } from "./errors.js";
import { canonicalJson } from "./helpers.js";
import { Logger } from "./logging.js";
import {
@@ -24,6 +25,11 @@ import {
kdf,
stringToBytes,
} from "./taler-crypto.js";
+import {
+ MerchantContractTerms,
+ MerchantContractTokenKind,
+ MerchantContractVersion,
+} from "./types-taler-merchant.js";
const logger = new Logger("contractTerms.ts");
@@ -220,6 +226,35 @@ export namespace ContractTermsUtil {
throw Error("not implemented yet");
}
+ export function validateParsed(contractTerms: MerchantContractTerms): boolean {
+ // validate trusted/expected domains
+ if (contractTerms.version === MerchantContractVersion.V1) {
+ const regex = new RegExp("^(\\*\\.)?([\\w\\d]+\\.)+[\\w\\d]+$");
+ for (const slug in contractTerms.token_families) {
+ const family = contractTerms.token_families[slug];
+ var domains: string[] = [];
+ switch (family.details.class) {
+ case MerchantContractTokenKind.Subscription:
+ domains.push(...family.details.trusted_domains);
+ break;
+ case MerchantContractTokenKind.Discount:
+ domains.push(...family.details.expected_domains);
+ break;
+ default:
+ assertUnreachable(family.details);
+ }
+
+ for (const domain in domains) {
+ if (domain !== "*" && !regex.test(domain)) {
+ return false;
+ }
+ }
+ }
+ }
+
+ return true;
+ }
+
/**
* Hash a contract terms object. Forgettable fields
* are scrubbed and JSON canonicalization is applied
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -904,20 +904,12 @@ export interface PaymentTokenAvailabilityDetails {
*/
tokensUntrusted: number;
- /**
- * Number of tokens with a malformed domain.
- *
- * Cannot be used to pay, so an error should be displayed.
- */
- tokensInvalid: number;
-
perTokenFamily: {
[tokenIssuePubHash: string]: {
requested: number;
available: number;
unexpected: number;
untrusted: number;
- invalid: number;
};
};
}
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -1060,7 +1060,7 @@ async function processDownloadProposal(
// present. The wallet should never accept contract terms
// with missing information from the merchant.
- const isWellFormed = ContractTermsUtil.validateForgettable(
+ var isWellFormed = ContractTermsUtil.validateForgettable(
proposalResp.contract_terms,
);
@@ -1105,6 +1105,25 @@ async function processDownloadProposal(
);
}
+ isWellFormed = ContractTermsUtil.validateParsed(parsedContractTerms);
+
+ if (!isWellFormed) {
+ logger.trace(
+ `malformed contract terms: ${j2s(proposalResp.contract_terms)}`,
+ );
+ const err = makeErrorDetail(
+ TalerErrorCode.WALLET_CONTRACT_TERMS_MALFORMED,
+ {},
+ "validation for well-formedness failed",
+ );
+ await failProposalPermanently(wex, proposalId, err);
+ throw makePendingOperationFailedError(
+ err,
+ TransactionType.Payment,
+ proposalId,
+ );
+ }
+
const sigValid = await wex.cryptoApi.isValidContractTermsSignature({
contractTermsHash,
merchantPub: parsedContractTerms.merchant_pub,
@@ -1646,6 +1665,7 @@ async function handleInsufficientFunds(
}
// FIXME: Above code should go into the transaction.
+ // TODO: also do token re-selection.
await wex.db.runAllStoresReadWriteTx({}, async (tx) => {
const p = await tx.purchases.get(proposalId);
@@ -1663,10 +1683,25 @@ async function handleInsufficientFunds(
proposal,
);
- if (contractData.version !== undefined &&
- contractData.version !== MerchantContractVersion.V0
- ) {
- throw Error(`unsupported contract version ${contractData.version}`);
+ let amount: AmountString;
+ let maxFee: AmountString;
+ switch (contractData.version) {
+ case undefined:
+ case MerchantContractVersion.V0:
+ amount = contractData.amount;
+ maxFee = contractData.max_fee;
+ break;
+ case MerchantContractVersion.V1:
+ const index = p.choiceIndex;
+ if (index === undefined)
+ throw Error("choice index not specified for contract v1");
+ if ((index in contractData.choices))
+ throw Error(`invalid choice index ${index}`)
+ amount = contractData.choices[index].amount;
+ maxFee = contractData.choices[index].max_fee;
+ break;
+ default:
+ assertUnreachable(contractData);
}
for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
@@ -1687,8 +1722,8 @@ async function handleInsufficientFunds(
})),
},
restrictWireMethod: contractData.wire_method,
- contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
- depositFeeLimit: Amounts.parseOrThrow(contractData.max_fee),
+ contractTermsAmount: Amounts.parseOrThrow(amount),
+ depositFeeLimit: Amounts.parseOrThrow(maxFee),
prevPayCoins,
requiredMinimumAge: contractData.minimum_age,
});
@@ -1936,7 +1971,7 @@ async function checkPaymentByProposalId(
if (index === undefined)
throw Error("choice index not specified for contract v1");
if ((index in contractData.choices))
- throw Error(`invalid choice index ${index}`)
+ throw Error(`invalid choice index ${index}`);
amount = contractData.choices[index].amount;
break;
default:
@@ -2447,7 +2482,6 @@ export async function getChoicesForPayment(
tokensAvailable: 0,
tokensUnexpected: 0,
tokensUntrusted: 0,
- tokensInvalid: 0,
perTokenFamily: {},
},
});
@@ -2553,7 +2587,7 @@ async function calculateDefaultChoice(
case MerchantContractVersion.V0:
return {
defaultChoiceIndex: 0,
- automaticExecution: true,
+ automaticExecution: false,
};
case MerchantContractVersion.V1:
if (contractTerms.choices.length === 0)
@@ -2581,12 +2615,14 @@ async function calculateDefaultChoice(
}
// If the cheapest choice has one subscription input and one
- // subscription output of the same type, it can be automatically spent.
+ // subscription output of the same type, and it's free, then it can be
+ // automatically confirmed without user confirmation.
//
// TODO: in the future, a setting should allow the user to specify
// merchants where discounts should be automatically spent.
defaultChoiceIndex = cheapestPayableIndex;
- automaticExecution = cheapestPayableChoice.inputs.length === 1 &&
+ automaticExecution = Amounts.isZero(cheapestPayableChoice.amount) &&
+ cheapestPayableChoice.inputs.length === 1 &&
cheapestPayableChoice.outputs.length === 1 &&
cheapestPayableChoice.inputs[0].type === MerchantContractInputType.Token &&
cheapestPayableChoice.outputs[0].type === MerchantContractOutputType.Token &&
@@ -3285,11 +3321,9 @@ async function processPurchasePay(
throw Error("merchant payment signature invalid");
}
- if (slates && merchantResp.token_sigs) {
- if (merchantResp.token_sigs.length !== slates.length) {
- throw Error("merchant returned mismatching number of token signatures");
- }
-
+ if (slates?.length !== merchantResp.token_sigs?.length) {
+ throw Error("merchant returned mismatching number of token signatures");
+ } else if (slates && merchantResp.token_sigs) {
for (let i = 0; i < slates.length; i++) {
const slate = slates[i];
const sigEv = merchantResp.token_sigs[i];
@@ -3961,10 +3995,23 @@ async function processPurchaseAutoRefund(
const download = await expectProposalDownload(wex, purchase);
const contractData = download.contractData;
- if (contractData.version !== undefined &&
- contractData.version !== MerchantContractVersion.V0
- ) {
- throw Error(`unsupported contract version ${contractData.version}`);
+
+ let amount: AmountString;
+ switch (contractData.version) {
+ case undefined:
+ case MerchantContractVersion.V0:
+ amount = contractData.amount;
+ break;
+ case MerchantContractVersion.V1:
+ const index = purchase.choiceIndex;
+ if (index === undefined)
+ throw Error("choice index not specified for contract v1");
+ if ((index in contractData.choices))
+ throw Error(`invalid choice index ${index}`)
+ amount = contractData.choices[index].amount;
+ break;
+ default:
+ assertUnreachable(contractData);
}
const noAutoRefundOrExpired =
@@ -3981,7 +4028,7 @@ async function processPurchaseAutoRefund(
const refunds = await tx.refundGroups.indexes.byProposalId.getAll(
purchase.proposalId,
);
- const am = Amounts.parseOrThrow(contractData.amount);
+ const am = Amounts.parseOrThrow(amount);
return refunds.reduce((prev, cur) => {
if (
cur.status === RefundGroupStatus.Done ||
@@ -3995,7 +4042,7 @@ async function processPurchaseAutoRefund(
);
const fullyRefunded =
- Amounts.cmp(contractData.amount, totalKnownRefund) <= 0;
+ Amounts.cmp(amount, totalKnownRefund) <= 0;
// We stop with the auto-refund state when the auto-refund period
// is over or the product is already fully refunded.
@@ -4438,13 +4485,26 @@ async function storeRefunds(
const download = await expectProposalDownload(wex, purchase);
const contractData = download.contractData;
- if (contractData.version !== undefined &&
- contractData.version !== MerchantContractVersion.V0
- ) {
- throw Error(`unsupported contract version ${contractData.version}`);
+
+ let amount: AmountString;
+ switch (contractData.version) {
+ case undefined:
+ case MerchantContractVersion.V0:
+ amount = contractData.amount;
+ break;
+ case MerchantContractVersion.V1:
+ const index = purchase.choiceIndex;
+ if (index === undefined)
+ throw Error("choice index not specified for contract v1");
+ if ((index in contractData.choices))
+ throw Error(`invalid choice index ${index}`)
+ amount = contractData.choices[index].amount;
+ break;
+ default:
+ assertUnreachable(contractData);
}
- const currency = Amounts.currencyOf(contractData.amount);
+ const currency = Amounts.currencyOf(amount);
const transitions: {
transactionId: string;
@@ -4641,7 +4701,7 @@ async function storeRefunds(
await createRefreshGroup(
wex,
tx,
- Amounts.currencyOf(contractData.amount),
+ Amounts.currencyOf(amount),
refreshCoins,
RefreshReason.Refund,
// Since refunds are really just pseudo-transactions,
diff --git a/packages/taler-wallet-core/src/tokenSelection.test.ts b/packages/taler-wallet-core/src/tokenSelection.test.ts
@@ -47,13 +47,6 @@ test("match trusted_domains and expected_domains against merchant", (t) => {
t.assert(
verifyTokenMerchant(merchant2, merchant1, {
class: MerchantContractTokenKind.Discount,
- expected_domains: [".taLer*.net"],
- }) === TokenMerchantVerificationResult.Invalid,
- );
-
- t.assert(
- verifyTokenMerchant(merchant2, merchant1, {
- class: MerchantContractTokenKind.Discount,
expected_domains: ["*.taler.Net"],
}) === TokenMerchantVerificationResult.Automatic,
);
diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts
@@ -78,11 +78,6 @@ export enum TokenMerchantVerificationResult {
* User should be warned before using.
*/
Unexpected = "unexpected-domain",
-
- /**
- * Domain is not correctly formatted.
- */
- Invalid = "invalid-domain",
}
/**
@@ -137,7 +132,7 @@ export function verifyTokenMerchant(
for (let domain of domains) {
domain = domain.toLowerCase();
if (!regex.test(domain))
- return TokenMerchantVerificationResult.Invalid;
+ throw new Error("assertion failed");
if (warning) {
// If the two domains match exactly, no warning.
// If the domain has a wildcard, do multi-level matching.
@@ -187,7 +182,6 @@ export async function selectPayTokensInTx(
tokensAvailable: 0,
tokensUnexpected: 0,
tokensUntrusted: 0,
- tokensInvalid: 0,
perTokenFamily: {},
};
@@ -212,7 +206,6 @@ export async function selectPayTokensInTx(
available: 0,
unexpected: 0,
untrusted: 0,
- invalid: 0,
};
} else {
details.perTokenFamily[keyHash].requested += count;
@@ -224,7 +217,7 @@ export async function selectPayTokensInTx(
// - filter out expired/not-yet-valid tokens
// - filter out tokens with errors
// - sort ascending by expiration date
- // - choose the first token in the list
+ // - choose the first n tokens in the list
const usable = tokens.filter(tok =>
!tok.transactionId && AbsoluteTime.isBetween(
AbsoluteTime.now(),
@@ -249,10 +242,6 @@ export async function selectPayTokensInTx(
details.tokensUntrusted += 1;
details.perTokenFamily[tok.tokenIssuePubHash].untrusted += 1;
return false; // non-usable
- case TokenMerchantVerificationResult.Invalid:
- details.tokensInvalid += 1;
- details.perTokenFamily[tok.tokenIssuePubHash].invalid += 1;
- return false; // non-usable
default:
assertUnreachable(res);
}