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:
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));
}
}