taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-util/src/contract-terms.ts | 35+++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 8--------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackages/taler-wallet-core/src/tokenSelection.test.ts | 7-------
Mpackages/taler-wallet-core/src/tokenSelection.ts | 15++-------------
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); }