taler-typescript-core

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

commit a7259d73a9d1087245ce5afcd0a351a7f7a8ad09
parent 2f9787972171ef87c7fb7ba86825a3f8339a376b
Author: Iván Ávalos <avalos@disroot.org>
Date:   Thu, 20 Mar 2025 16:18:31 +0100

WIP: implement default choice and auto execution

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-tokens.ts | 6++++++
Mpackages/taler-util/src/types-taler-wallet.ts | 10++++++----
Mpackages/taler-wallet-core/src/db.ts | 2+-
Mpackages/taler-wallet-core/src/pay-merchant.ts | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-core/src/tokenSelection.test.ts | 12++++++------
Mpackages/taler-wallet-core/src/tokenSelection.ts | 87+++++++++++++++++++++++++++++++++++++++++++------------------------------------
6 files changed, 160 insertions(+), 64 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts @@ -191,6 +191,9 @@ export async function runWalletTokensTest(t: GlobalTestState) { transactionId: preparePayResult.transactionId, }); + t.assertTrue(choicesRes.defaultChoiceIndex === 1); + t.assertTrue(choicesRes.automaticExecution === false); + t.assertTrue(choicesRes.choices[0].status === ChoiceSelectionDetailType.PaymentPossible, ); @@ -241,6 +244,9 @@ export async function runWalletTokensTest(t: GlobalTestState) { transactionId: preparePayResult.transactionId, }); + t.assertTrue(choicesRes.defaultChoiceIndex === 0); + t.assertTrue(choicesRes.automaticExecution === false); + t.assertTrue(choicesRes.choices[1].status === ChoiceSelectionDetailType.InsufficientBalance, ); diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2380,8 +2380,9 @@ export type GetChoicesForPaymentResult = { * Index of the choice in @e choices array to present * to the user as default. * - * Won´t be set if no default selection is configured, - * otherwise, it will always be 0 for v0 orders. + * Won´t be set if no default selection is configured + * or no choice is payable, otherwise, it will always + * be 0 for v0 orders. */ defaultChoiceIndex?: number; @@ -2393,9 +2394,10 @@ export type GetChoicesForPaymentResult = { * If true, the wallet should call `confirmPay' * immediately afterwards, if false, the user * should be first prompted to select and - * confirm a choice. + * confirm a choice. Undefined when no choices + * are payable. */ - automaticExecution: boolean; + automaticExecution?: boolean; } export interface SharePaymentRequest { diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -2857,7 +2857,7 @@ export const WalletStoresV1 = { keyPath: "tokenUsePub", }), { - bySlug: describeIndex("bySlug", "slug"), + byTokenIssuePubHash: describeIndex("byTokenIssuePubHash", "tokenIssuePubHash"), byPurchaseIdAndChoiceIndex: describeIndex( "byPurchaseIdAndChoiceIndex", ["purchaseId", "choiceIndex"], diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -63,6 +63,7 @@ import { makePendingOperationFailedError, makeTalerErrorDetail, MerchantCoinRefundStatus, + MerchantContractInputType, MerchantContractOutputType, MerchantContractTerms, MerchantContractTermsV1, @@ -1662,7 +1663,9 @@ async function handleInsufficientFunds( proposal, ); - if (contractData.version !== MerchantContractVersion.V0) { + if (contractData.version !== undefined && + contractData.version !== MerchantContractVersion.V0 + ) { throw Error(`unsupported contract version ${contractData.version}`); } @@ -1924,6 +1927,7 @@ async function checkPaymentByProposalId( let amount: AmountString; switch (contractData.version) { + case undefined: case MerchantContractVersion.V0: amount = contractData.amount; break; @@ -1936,7 +1940,7 @@ async function checkPaymentByProposalId( amount = contractData.choices[index].amount; break; default: - throw Error(`unsupported contract version ${contractData.version}`); + assertUnreachable(contractData); } return { @@ -1957,6 +1961,7 @@ async function checkPaymentByProposalId( let amount: AmountString; switch (download.contractData.version) { + case undefined: case MerchantContractVersion.V0: amount = download.contractData.amount; break; @@ -1969,7 +1974,7 @@ async function checkPaymentByProposalId( amount = download.contractData.choices[index].amount; break; default: - throw Error(`unsupported contract version ${contractData.version}`); + assertUnreachable(download.contractData); } return { @@ -1991,6 +1996,7 @@ async function checkPaymentByProposalId( let amount: AmountString; switch (download.contractData.version) { + case undefined: case MerchantContractVersion.V0: amount = download.contractData.amount; break; @@ -2003,7 +2009,7 @@ async function checkPaymentByProposalId( amount = download.contractData.choices[index].amount; break; default: - throw Error(`unsupported contract version ${contractData.version}`); + assertUnreachable(download.contractData); } return { @@ -2431,6 +2437,7 @@ export async function getChoicesForPayment( const tokenSels: SelectPayTokensResult[] = []; const contractTerms: MerchantContractTerms = d.contractData; switch (contractTerms.version) { + case undefined: case MerchantContractVersion.V0: tokenSels.push({ type: "success", @@ -2455,7 +2462,7 @@ export async function getChoicesForPayment( } break; default: - throw Error(`invalid contract version`); + assertUnreachable(contractTerms); } for (let i = 0; i < tokenSels.length; i++) { @@ -2467,6 +2474,7 @@ export async function getChoicesForPayment( let amount: AmountString; let maxFee: AmountString; switch (contractTerms.version) { + case undefined: case MerchantContractVersion.V0: amount = contractTerms.amount; maxFee = contractTerms.max_fee; @@ -2476,7 +2484,7 @@ export async function getChoicesForPayment( maxFee = contractTerms.choices[i].max_fee; break; default: - throw Error(`invalid contract version`); + assertUnreachable(contractTerms); } const selectCoinsResult = await selectPayCoinsInTx(wex, tx, { @@ -2524,9 +2532,76 @@ export async function getChoicesForPayment( return { choices, - // TODO: calculate! - defaultChoiceIndex: 0, - automaticExecution: false, + ...(await calculateDefaultChoice( + wex, choices, d.contractData, + )), + }; +} + +async function calculateDefaultChoice( + wex: WalletExecutionContext, + choiceDetails: ChoiceSelectionDetail[], + contractTerms: MerchantContractTerms, +): Promise<{ + defaultChoiceIndex?: number, + automaticExecution?: boolean, +}> { + var defaultChoiceIndex: number | undefined = undefined; + var automaticExecution: boolean | undefined = undefined; + switch (contractTerms.version) { + case undefined: + case MerchantContractVersion.V0: + return { + defaultChoiceIndex: 0, + automaticExecution: true, + }; + case MerchantContractVersion.V1: + if (contractTerms.choices.length === 0) + throw Error(`contract v1 has no choices`); + + // If there's only one choice, use it. + if (contractTerms.choices.length === 1 && + choiceDetails[0].status === ChoiceSelectionDetailType.PaymentPossible + ) { + defaultChoiceIndex = 0; + break; + } + + // If there are more, choose the payable one with lowest amount. + var cheapestPayableIndex = 0; + var cheapestPayableChoice = contractTerms.choices[0]; + for (let i = 1; i < contractTerms.choices.length; i++) { + const choice = contractTerms.choices[i]; + const details = choiceDetails[i]; + if (details.status === ChoiceSelectionDetailType.PaymentPossible && + choice.amount < cheapestPayableChoice.amount) { + cheapestPayableIndex = i; + cheapestPayableChoice = choice; + } + } + + // If the cheapest choice has one subscription input and one + // subscription output of the same type, it can be automatically spent. + // + // 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 && + cheapestPayableChoice.outputs.length === 1 && + cheapestPayableChoice.inputs[0].type === MerchantContractInputType.Token && + cheapestPayableChoice.outputs[0].type === MerchantContractOutputType.Token && + (cheapestPayableChoice.inputs[0].count ?? 1) === 1 && + (cheapestPayableChoice.outputs[0].count ?? 1) === 1 && + cheapestPayableChoice.inputs[0].token_family_slug === + cheapestPayableChoice.inputs[0].token_family_slug; + break; + default: + assertUnreachable(contractTerms); + } + + return { + defaultChoiceIndex, + automaticExecution, }; } @@ -2606,6 +2681,7 @@ export async function confirmPay( let currency: string; const contractData = d.contractData; switch (contractData.version) { + case undefined: case MerchantContractVersion.V0: amount = contractData.amount; maxFee = contractData.max_fee; @@ -2621,7 +2697,7 @@ export async function confirmPay( currency = Amounts.currencyOf(amount); break; default: - throw Error(`unsupported contract version ${contractData.version}`); + assertUnreachable(contractData); } let sessionId: string | undefined; @@ -2937,6 +3013,7 @@ async function processPurchasePay( let maxFee: AmountString; let currency: string; switch (contractData.version) { + case undefined: case MerchantContractVersion.V0: amount = contractData.amount; maxFee = contractData.max_fee; @@ -2952,7 +3029,7 @@ async function processPurchasePay( currency = Amounts.currencyOf(amount); break; default: - throw Error(`unsupported contract version ${contractData.version}`); + assertUnreachable(contractData); } if (!payInfo.payCoinSelection) { @@ -3884,7 +3961,9 @@ async function processPurchaseAutoRefund( const download = await expectProposalDownload(wex, purchase); const contractData = download.contractData; - if (contractData.version !== MerchantContractVersion.V0) { + if (contractData.version !== undefined && + contractData.version !== MerchantContractVersion.V0 + ) { throw Error(`unsupported contract version ${contractData.version}`); } @@ -4359,7 +4438,9 @@ async function storeRefunds( const download = await expectProposalDownload(wex, purchase); const contractData = download.contractData; - if (contractData.version !== MerchantContractVersion.V0) { + if (contractData.version !== undefined && + contractData.version !== MerchantContractVersion.V0 + ) { throw Error(`unsupported contract version ${contractData.version}`); } diff --git a/packages/taler-wallet-core/src/tokenSelection.test.ts b/packages/taler-wallet-core/src/tokenSelection.test.ts @@ -23,10 +23,10 @@ import { } from "./tokenSelection.js"; test("match trusted_domains and expected_domains against merchant", (t) => { - const merchant0 = "https://merchant.net/"; - const merchant1 = "https://backend.test.taler.net/"; - const merchant2 = "https://backend.demo.taler.net/"; - const merchant3 = "https://backend.demo.evil.net/"; + const merchant0 = "https://Merchant.neT/"; + const merchant1 = "https://backend.Test.taLer.net/"; + const merchant2 = "https://backend.dEmo.taleR.nEt/"; + const merchant3 = "https://backend.deMo.evil.net/"; // (merchantBaseUrl, tokenMerchantBaseUrl, tokenDetails) @@ -47,14 +47,14 @@ test("match trusted_domains and expected_domains against merchant", (t) => { t.assert( verifyTokenMerchant(merchant2, merchant1, { class: MerchantContractTokenKind.Discount, - expected_domains: [".taler*.net"], + expected_domains: [".taLer*.net"], }) === TokenMerchantVerificationResult.Invalid, ); t.assert( verifyTokenMerchant(merchant2, merchant1, { class: MerchantContractTokenKind.Discount, - expected_domains: ["*.taler.net"], + expected_domains: ["*.taler.Net"], }) === TokenMerchantVerificationResult.Automatic, ); diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -16,6 +16,8 @@ import { AbsoluteTime, assertUnreachable, + encodeCrock, + hashTokenIssuePub, j2s, Logger, MerchantContractInputType, @@ -96,10 +98,10 @@ export function verifyTokenMerchant( tokenDetails: MerchantContractTokenDetails, ): TokenMerchantVerificationResult { const parsedUrl = new URL(merchantBaseUrl); - const merchantDomain = parsedUrl.hostname; + const merchantDomain = parsedUrl.hostname.toLowerCase(); const parsedTokenUrl = new URL(tokenMerchantBaseUrl); - const tokenDomain = parsedTokenUrl.hostname; + const tokenDomain = parsedTokenUrl.hostname.toLowerCase(); const domains: string[] = []; switch (tokenDetails.class) { @@ -130,35 +132,32 @@ export function verifyTokenMerchant( } } - var unexpected = false; - var untrusted = false; + var warning = true; const regex = new RegExp("^(\\*\\.)?([\\w\\d]+\\.)+[\\w\\d]+$"); - for (const domain of domains) { + for (let domain of domains) { + domain = domain.toLowerCase(); if (!regex.test(domain)) return TokenMerchantVerificationResult.Invalid; - const matcher = new RegExp("^" + domain - .replace(".", "\\.") - .replace("*", "(([\\w\\d]+\.)+[\\w\\d]+)") + "$"); - if (matcher.test(merchantDomain)) { - continue; - } else { - switch (tokenDetails.class) { - case MerchantContractTokenKind.Discount: - unexpected = true; - continue; - case MerchantContractTokenKind.Subscription: - untrusted = true; - continue; - default: - assertUnreachable(tokenDetails); - } + if (warning) { + // If the two domains match exactly, no warning. + // If the domain has a wildcard, do multi-level matching. + warning = domain !== merchantDomain && ( + domain.startsWith("*.") + && !merchantDomain.endsWith(domain.slice(2)) + ); } } - if (untrusted) - return TokenMerchantVerificationResult.Untrusted; - if (unexpected) - return TokenMerchantVerificationResult.Unexpected; + if (warning) { + switch (tokenDetails.class) { + case MerchantContractTokenKind.Discount: + return TokenMerchantVerificationResult.Unexpected; + case MerchantContractTokenKind.Subscription: + return TokenMerchantVerificationResult.Untrusted; + default: + assertUnreachable(tokenDetails); + } + } return TokenMerchantVerificationResult.Automatic; } @@ -199,17 +198,25 @@ export async function selectPayTokensInTx( if (input.type == MerchantContractInputType.Token) { const count = input.count ?? 1; const slug = input.token_family_slug; + const keys = req.contractTerms.token_families[slug].keys; details.tokensRequested += count; - if (details.perTokenFamily[slug] === undefined) { - details.perTokenFamily[slug] = { - requested: count, - available: 0, - unexpected: 0, - untrusted: 0, - invalid: 0, - }; - } else { - details.perTokenFamily[slug].requested += count; + + const tokens: TokenRecord[] = []; + for (const key of keys) { + const keyHash = encodeCrock(hashTokenIssuePub(key)); + const t = await tx.tokens.indexes.byTokenIssuePubHash.getAll(keyHash); + tokens.push(...t); + if (details.perTokenFamily[keyHash] === undefined) { + details.perTokenFamily[keyHash] = { + requested: count, + available: 0, + unexpected: 0, + untrusted: 0, + invalid: 0, + }; + } else { + details.perTokenFamily[keyHash].requested += count; + } } // Selection algorithm: @@ -218,7 +225,6 @@ export async function selectPayTokensInTx( // - filter out tokens with errors // - sort ascending by expiration date // - choose the first token in the list - const tokens = await tx.tokens.indexes.bySlug.getAll(input.token_family_slug); const usable = tokens.filter(tok => !tok.transactionId && AbsoluteTime.isBetween( AbsoluteTime.now(), @@ -232,18 +238,20 @@ export async function selectPayTokensInTx( ); switch (res) { case TokenMerchantVerificationResult.Automatic: + details.perTokenFamily[tok.tokenIssuePubHash].available += 1; return true; // usable case TokenMerchantVerificationResult.Unexpected: details.tokensUnexpected += 1; - details.perTokenFamily[slug].unexpected += 1; + details.perTokenFamily[tok.tokenIssuePubHash].unexpected += 1; + details.perTokenFamily[tok.tokenIssuePubHash].available += 1; return true; // usable case TokenMerchantVerificationResult.Untrusted: details.tokensUntrusted += 1; - details.perTokenFamily[slug].untrusted += 1; + details.perTokenFamily[tok.tokenIssuePubHash].untrusted += 1; return false; // non-usable case TokenMerchantVerificationResult.Invalid: details.tokensInvalid += 1; - details.perTokenFamily[slug].invalid += 1; + details.perTokenFamily[tok.tokenIssuePubHash].invalid += 1; return false; // non-usable default: assertUnreachable(res); @@ -256,7 +264,6 @@ export async function selectPayTokensInTx( } details.tokensAvailable += count; - details.perTokenFamily[slug].available += count; records.push(...usable.slice(0, count)); } }