commit a3ab13cd77db45fffb69714e3badbfb6674e0920
parent 40c7fd37b6081a418d97632b7d23fe429b3500c4
Author: Iván Ávalos <avalos@disroot.org>
Date: Tue, 18 Mar 2025 20:13:38 +0100
WIP: getChoicesForPayment op
Diffstat:
7 files changed, 346 insertions(+), 34 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts
@@ -25,6 +25,7 @@ import { GlobalTestState } from "../harness/harness.js";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import {
AbsoluteTime,
+ ChoiceSelectionDetailType,
Duration,
Order,
OrderInputType,
@@ -33,6 +34,7 @@ import {
succeedOrThrow,
TalerMerchantInstanceHttpClient,
TalerProtocolTimestamp,
+ TokenAvailabilityHint,
TokenFamilyKind,
} from "@gnu-taler/taler-util";
@@ -185,6 +187,22 @@ export async function runWalletTokensTest(t: GlobalTestState) {
preparePayResult.status === PreparePayResultType.ChoiceSelection,
);
+ {
+ const choicesRes = await walletClient.call(WalletApiOperation.GetChoicesForPayment, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(choicesRes.choices[0].status ===
+ ChoiceSelectionDetailType.PaymentPossible,
+ );
+
+ const tokenDetails = choicesRes.choices[1].tokenDetails;
+ t.assertTrue(tokenDetails !== undefined);
+ t.assertTrue(tokenDetails.causeHint === undefined);
+ t.assertTrue(tokenDetails.tokensAvailable === 1);
+ t.assertTrue(tokenDetails.tokensRequested === 1);
+ }
+
await walletClient.call(WalletApiOperation.ConfirmPay, {
transactionId: preparePayResult.transactionId,
choiceIndex: 1,
@@ -221,12 +239,27 @@ export async function runWalletTokensTest(t: GlobalTestState) {
preparePayResult.status === PreparePayResultType.ChoiceSelection,
);
+ {
+ const choicesRes = await walletClient.call(WalletApiOperation.GetChoicesForPayment, {
+ transactionId: preparePayResult.transactionId,
+ });
+
+ t.assertTrue(choicesRes.choices[1].status ===
+ ChoiceSelectionDetailType.InsufficientBalance,
+ );
+
+ const tokenDetails = choicesRes.choices[1].tokenDetails;
+ t.assertTrue(tokenDetails?.causeHint === TokenAvailabilityHint.TokensInsufficient);
+ t.assertTrue(tokenDetails.tokensAvailable === 0);
+ t.assertTrue(tokenDetails.tokensRequested === 1);
+ }
+
// should fail because we have no tokens left
t.assertThrowsAsync(async () => {
await walletClient.call(WalletApiOperation.ConfirmPay, {
transactionId: preparePayResult.transactionId,
choiceIndex: 1,
- })
+ });
});
orderStatus = succeedOrThrow(
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -878,6 +878,67 @@ export interface PaymentInsufficientBalanceDetails {
};
}
+export enum TokenAvailabilityHint {
+ /**
+ * Not enough tokens to pay the order.
+ * Should result in an error.
+ */
+ TokensInsufficient = "tokens-insufficient",
+
+ /**
+ * Merchant is untrusted by one or more tokens.
+ * Should result in an error.
+ */
+ TokensUntrusted = "tokens-untrusted",
+
+ /**
+ * Merchant is unexpected by one or more tokens.
+ * Should result in a warning.
+ */
+ TokensUnexpected = "tokens-unexpected",
+}
+
+export interface PaymentTokenAvailabilityDetails {
+ /**
+ * Hint for errors or warnings in the token selection, if any.
+ */
+ causeHint?: TokenAvailabilityHint;
+
+ /**
+ * Number of tokens requested by the merchant.
+ */
+ tokensRequested: number;
+
+ /**
+ * Number of tokens available to use.
+ */
+ tokensAvailable: number;
+
+ /**
+ * Number of tokens for which the merchant is unexpected.
+ *
+ * Can be used to pay (i.e. with forced selection),
+ * but a warning should be displayed to the user.
+ */
+ tokensUnexpected: number;
+
+ /**
+ * Number of tokens for which the merchant is untrusted.
+ *
+ * Cannot be used to pay, so an error should be displayed.
+ */
+ tokensUntrusted: number;
+
+ perTokenFamily: {
+ [tokenIssuePubHash: string]: {
+ requested: number;
+ available: number;
+ unexpected: number;
+ untrusted: number;
+ };
+ };
+}
+
export const codecForPayMerchantInsufficientBalanceDetails =
(): Codec<PaymentInsufficientBalanceDetails> =>
buildCodecForObject<PaymentInsufficientBalanceDetails>()
@@ -2291,12 +2352,14 @@ export const codecForPreparePayRequest = (): Codec<PreparePayRequest> =>
export interface GetChoicesForPaymentRequest {
transactionId: string;
+ forcedCoinSel?: ForcedCoinSel;
}
export const codecForGetChoicesForPaymentRequest =
(): Codec<GetChoicesForPaymentRequest> =>
buildCodecForObject<GetChoicesForPaymentRequest>()
.property("transactionId", codecForString())
+ .property("forcedCoinSel", codecForAny())
.build("GetChoicesForPaymentRequest");
export enum ChoiceSelectionDetailType {
@@ -2310,11 +2373,13 @@ export type ChoiceSelectionDetail =
export interface ChoiceSelectionDetailPaymentPossible {
status: ChoiceSelectionDetailType.PaymentPossible;
+ tokenDetails?: PaymentTokenAvailabilityDetails;
}
export interface ChoiceSelectionDetailInsufficientBalance {
status: ChoiceSelectionDetailType.InsufficientBalance;
- balanceDetail: PaymentInsufficientBalanceDetails;
+ balanceDetails?: PaymentInsufficientBalanceDetails;
+ tokenDetails?: PaymentTokenAvailabilityDetails;
}
export type GetChoicesForPaymentResult = {
diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts
@@ -288,7 +288,9 @@ export async function spendTokens(
}
}
+ logger.trace(`spending token ${token.tokenUsePub} in ${tsi.transactionId}`);
token.transactionId = tsi.transactionId;
+ await tx.tokens.put(token);
}
}
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -35,6 +35,8 @@ import {
checkDbInvariant,
CheckPayTemplateReponse,
CheckPayTemplateRequest,
+ ChoiceSelectionDetail,
+ ChoiceSelectionDetailType,
codecForAbortResponse,
codecForMerchantContractTerms,
codecForMerchantOrderStatusPaid,
@@ -51,6 +53,7 @@ import {
Duration,
encodeCrock,
ForcedCoinSel,
+ GetChoicesForPaymentResult,
getRandomBytes,
hashPayWalletData,
HttpStatusCode,
@@ -71,6 +74,7 @@ import {
parsePayTemplateUri,
parsePayUri,
parseTalerUri,
+ PaymentInsufficientBalanceDetails,
PayWalletData,
PreparePayResult,
PreparePayResultType,
@@ -2394,6 +2398,139 @@ async function waitPaymentResult(
});
}
+export async function getChoicesForPayment(
+ wex: WalletExecutionContext,
+ transactionId: string,
+ forcedCoinSel?: ForcedCoinSel,
+): Promise<GetChoicesForPaymentResult> {
+ const parsedTx = parseTransactionIdentifier(transactionId);
+ if (parsedTx?.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ const proposalId = parsedTx.proposalId;
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ const proposal = await wex.db.runReadOnlyTx(
+ { storeNames: ["purchases"] },
+ async (tx) => {
+ return tx.purchases.get(proposalId);
+ },
+ );
+
+ if (!proposal) {
+ throw Error(`proposal with id ${proposalId} not found`);
+ }
+
+ const d = await expectProposalDownload(wex, proposal);
+ if (!d) {
+ throw Error("proposal is in invalid state");
+ }
+
+ const choices: ChoiceSelectionDetail[] = [];
+ await wex.db.runAllStoresReadOnlyTx(
+ {},
+ async (tx) => {
+ const tokenSels: SelectPayTokensResult[] = [];
+ const contractTerms: MerchantContractTerms = d.contractData;
+ switch (contractTerms.version) {
+ case MerchantContractVersion.V0:
+ tokenSels.push({
+ type: "success",
+ tokens: [],
+ details: {
+ tokensRequested: 0,
+ tokensAvailable: 0,
+ tokensUnexpected: 0,
+ tokensUntrusted: 0,
+ perTokenFamily: {},
+ },
+ });
+ break;
+ case MerchantContractVersion.V1:
+ for (let i = 0; i < contractTerms.choices.length; i++) {
+ tokenSels.push(await selectPayTokensInTx(tx, {
+ proposalId,
+ choiceIndex: i,
+ contractTerms: contractTerms,
+ }));
+ }
+ break;
+ default:
+ throw Error(`invalid contract version`);
+ }
+
+ for (let i = 0; i < tokenSels.length; i++) {
+ const tokenSelection = tokenSels[i];
+ logger.trace("token selection result", tokenSelection);
+
+ let balanceDetails: PaymentInsufficientBalanceDetails | undefined = undefined;
+
+ let amount: AmountString;
+ let maxFee: AmountString;
+ switch (contractTerms.version) {
+ case MerchantContractVersion.V0:
+ amount = contractTerms.amount;
+ maxFee = contractTerms.max_fee;
+ break;
+ case MerchantContractVersion.V1:
+ amount = contractTerms.choices[i].amount;
+ maxFee = contractTerms.choices[i].max_fee;
+ break;
+ default:
+ throw Error(`invalid contract version`);
+ }
+
+ const selectCoinsResult = await selectPayCoinsInTx(wex, tx, {
+ restrictExchanges: {
+ auditors: [],
+ exchanges: contractTerms.exchanges.map(ex => ({
+ exchangeBaseUrl: ex.url,
+ exchangePub: ex.master_pub,
+ })),
+ },
+ restrictWireMethod: contractTerms.wire_method,
+ contractTermsAmount: Amounts.parseOrThrow(amount),
+ depositFeeLimit: Amounts.parseOrThrow(maxFee),
+ prevPayCoins: [],
+ requiredMinimumAge: contractTerms.minimum_age,
+ forcedSelection: forcedCoinSel,
+ });
+
+ let coins: SelectedProspectiveCoin[] | undefined = undefined;
+ logger.trace("coin selection result", selectCoinsResult);
+
+ switch (selectCoinsResult.type) {
+ case "failure": {
+ balanceDetails = selectCoinsResult.insufficientBalanceDetails;
+ }
+ }
+
+ let choice: ChoiceSelectionDetail;
+ if (tokenSelection.type === "failure" || selectCoinsResult.type === "failure") {
+ choice = {
+ status: ChoiceSelectionDetailType.InsufficientBalance,
+ balanceDetails: balanceDetails,
+ tokenDetails: tokenSelection.details,
+ };
+ } else {
+ choice = {
+ status: ChoiceSelectionDetailType.PaymentPossible,
+ tokenDetails: tokenSelection.details,
+ };
+ }
+
+ choices.push(choice);
+ }
+ },
+ );
+
+ return {
+ choices,
+ // TODO: calculate
+ defaultChoiceIndex: 0,
+ automaticExecution: false,
+ };
+}
+
/**
* Confirm payment for a proposal previously claimed by the wallet.
*/
@@ -2402,7 +2539,6 @@ export async function confirmPay(
transactionId: string,
sessionIdOverride?: string,
forcedCoinSel?: ForcedCoinSel,
- forcedTokenSel?: boolean,
choiceIndex?: number,
): Promise<ConfirmPayResult> {
const parsedTx = parseTransactionIdentifier(transactionId);
@@ -2515,7 +2651,6 @@ export async function confirmPay(
proposalId,
choiceIndex: choiceIndex!,
contractTerms: contractData,
- forcedTokenSel: forcedTokenSel,
});
switch (selectTokensResult.type) {
@@ -2585,11 +2720,9 @@ export async function confirmPay(
};
let tokenPubs: string[] | undefined = undefined;
if (selectTokensResult?.type === "success") {
- const tokens = selectTokensResult.tokenSel.tokens.map(t => t.record);
+ const tokens = selectTokensResult.tokens;
tokenPubs = tokens.map(t => t.tokenUsePub);
- p.payInfo.payTokenSelection = {
- tokenPubs: selectTokensResult.tokenSel.tokens.map(t => t.record.tokenUsePub),
- };
+ p.payInfo.payTokenSelection = { tokenPubs };
}
if (selectCoinsResult.type === "success") {
p.payInfo.payCoinSelection = {
diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts
@@ -1,10 +1,13 @@
import {
AbsoluteTime,
+ assertUnreachable,
j2s,
Logger,
MerchantContractInputType,
MerchantContractTermsV1,
MerchantContractTokenKind,
+ PaymentTokenAvailabilityDetails,
+ TokenAvailabilityHint,
} from "@gnu-taler/taler-util";
import { timestampProtocolFromDb, TokenRecord, WalletDbReadOnlyTransaction } from "./db.js";
import { WalletExecutionContext } from "./index.js";
@@ -15,19 +18,23 @@ export interface SelectPayTokensRequest {
proposalId: string;
choiceIndex: number;
contractTerms: MerchantContractTermsV1;
- forcedTokenSel?: boolean;
}
-export interface SelectedTokens {
- tokens: {
- record: TokenRecord,
- verification: TokenMerchantVerificationResult,
- }[],
+export interface SelectPayTokensAllChoicesRequest {
+ proposalId: string;
+ contractTerms: MerchantContractTermsV1;
}
export type SelectPayTokensResult =
- | { type: "failure" }
- | { type: "success", tokenSel: SelectedTokens }
+ | {
+ type: "failure",
+ details: PaymentTokenAvailabilityDetails,
+ }
+ | {
+ type: "success",
+ tokens: TokenRecord[],
+ details: PaymentTokenAvailabilityDetails,
+ };
export enum TokenMerchantVerificationResult {
/**
@@ -88,11 +95,39 @@ export async function selectPayTokensInTx(
throw Error(`proposal ${req.proposalId} could not be found`);
}
- const selection: SelectedTokens = {tokens: []};
+ const records: TokenRecord[] = [];
+ const details: PaymentTokenAvailabilityDetails = {
+ tokensRequested: 0,
+ tokensAvailable: 0,
+ tokensUnexpected: 0,
+ tokensUntrusted: 0,
+ perTokenFamily: {},
+ };
+
+ var insufficient = false;
const inputs = req.contractTerms.choices[req.choiceIndex].inputs;
for (const input of inputs) {
if (input.type == MerchantContractInputType.Token) {
- // token with earliest expiration date that is still valid
+ const count = input.count ?? 1;
+ const slug = input.token_family_slug;
+ details.tokensRequested += count;
+ if (details.perTokenFamily[slug] === undefined) {
+ details.perTokenFamily[slug] = {
+ requested: count,
+ available: 0,
+ unexpected: 0,
+ untrusted: 0,
+ };
+ } else {
+ details.perTokenFamily[slug].requested += count;
+ }
+
+ // Selection algorithm:
+ // - filter out spent tokens (i.e. no transactionId)
+ // - filter out expired/not-yet-valid tokens
+ // - 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(
@@ -101,27 +136,52 @@ export async function selectPayTokensInTx(
AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)),
)).filter(tok => {
const res = verifyTokenMerchant(proposal.merchantBaseUrl, tok);
- return res === TokenMerchantVerificationResult.Automatic || (
- req.forcedTokenSel && res === TokenMerchantVerificationResult.Unexpected);
- }).sort((a, b) => a.validBefore - b.validBefore).at(0);
- if (!usable) {
- return { type: "failure" };
+ switch (res) {
+ case TokenMerchantVerificationResult.Automatic:
+ details.tokensAvailable += 1;
+ details.perTokenFamily[slug].available += 1;
+ break;
+ case TokenMerchantVerificationResult.Unexpected:
+ details.tokensUnexpected += 1;
+ details.tokensAvailable += 1;
+ details.perTokenFamily[slug].unexpected += 1;
+ details.perTokenFamily[slug].available += 1;
+ details.causeHint = TokenAvailabilityHint.TokensUnexpected;
+ break;
+ case TokenMerchantVerificationResult.Untrusted:
+ details.tokensUntrusted += 1;
+ details.perTokenFamily[slug].untrusted += 1;
+ details.causeHint = TokenAvailabilityHint.TokensUntrusted;
+ break;
+ }
+ return (
+ res === TokenMerchantVerificationResult.Automatic ||
+ res === TokenMerchantVerificationResult.Unexpected
+ );
+ }).sort((a, b) => a.validBefore - b.validBefore);
+
+ if (usable.length < count) {
+ insufficient = true;
+ continue;
}
- selection.tokens.push({
- record: usable,
- verification: verifyTokenMerchant(
- proposal.merchantBaseUrl,
- usable,
- ),
- });
+ records.push(...usable.slice(0, count));
}
}
+ if (insufficient) {
+ details.causeHint = TokenAvailabilityHint.TokensInsufficient;
+ return {
+ type: "failure",
+ details: details,
+ };
+ }
+
return {
type: "success",
- tokenSel: selection,
- };
+ tokens: records,
+ details: details,
+ }
}
export async function selectPayTokens(
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -236,6 +236,7 @@ export enum WalletApiOperation {
ResumeTransaction = "resumeTransaction",
DeleteTransaction = "deleteTransaction",
RetryTransaction = "retryTransaction",
+ GetChoicesForPayment = "getChoicesForPayment",
ConfirmPay = "confirmPay",
DumpCoins = "dumpCoins",
SetCoinSuspended = "setCoinSuspended",
@@ -594,7 +595,7 @@ export type PreparePayForUriOp = {
* will be undefined or set to false.
*/
export type GetChoicesForPaymentOp = {
- // op: WalletApiOperation.GetChoicesForPayment;
+ op: WalletApiOperation.GetChoicesForPayment;
request: GetChoicesForPaymentRequest;
response: GetChoicesForPaymentResult;
}
@@ -1381,6 +1382,7 @@ export type WalletOperations = {
[WalletApiOperation.PreparePayForTemplate]: PreparePayForTemplateOp;
[WalletApiOperation.GetContractTermsDetails]: GetContractTermsDetailsOp;
[WalletApiOperation.WithdrawTestkudos]: WithdrawTestkudosOp;
+ [WalletApiOperation.GetChoicesForPayment]: GetChoicesForPaymentOp;
[WalletApiOperation.ConfirmPay]: ConfirmPayOp;
[WalletApiOperation.AbortTransaction]: AbortTransactionOp;
[WalletApiOperation.FailTransaction]: FailTransactionOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
@@ -68,6 +68,8 @@ import {
GetBankAccountByIdResponse,
GetBankingChoicesForPaytoRequest,
GetBankingChoicesForPaytoResponse,
+ GetChoicesForPaymentRequest,
+ GetChoicesForPaymentResult,
GetContractTermsDetailsRequest,
GetCurrencySpecificationRequest,
GetCurrencySpecificationResponse,
@@ -164,6 +166,7 @@ import {
codecForGetBalanceDetailRequest,
codecForGetBankAccountByIdRequest,
codecForGetBankingChoicesForPaytoRequest,
+ codecForGetChoicesForPaymentRequest,
codecForGetContractTermsDetails,
codecForGetCurrencyInfoRequest,
codecForGetDepositWireTypesForCurrencyRequest,
@@ -310,6 +313,7 @@ import {
import {
checkPayForTemplate,
confirmPay,
+ getChoicesForPayment,
getContractTermsDetails,
preparePayForTemplate,
preparePayForUri,
@@ -1179,6 +1183,16 @@ async function handleGetBankingChoicesForPayto(
}
}
+async function handleGetChoicesForPayment(
+ wex: WalletExecutionContext,
+ req: GetChoicesForPaymentRequest,
+): Promise<GetChoicesForPaymentResult> {
+ return await getChoicesForPayment(wex,
+ req.transactionId,
+ req.forcedCoinSel,
+ );
+}
+
async function handleConfirmPay(
wex: WalletExecutionContext,
req: ConfirmPayRequest,
@@ -1187,7 +1201,6 @@ async function handleConfirmPay(
req.transactionId,
req.sessionId,
undefined,
- req.forcedTokenSel,
req.choiceIndex,
);
}
@@ -1969,6 +1982,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = {
codec: codecForGetQrCodesForPaytoRequest(),
handler: handleGetQrCodesForPayto,
},
+ [WalletApiOperation.GetChoicesForPayment]: {
+ codec: codecForGetChoicesForPaymentRequest(),
+ handler: handleGetChoicesForPayment,
+ },
[WalletApiOperation.ConfirmPay]: {
codec: codecForConfirmPayRequest(),
handler: handleConfirmPay,