taler-typescript-core

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

commit a3ab13cd77db45fffb69714e3badbfb6674e0920
parent 40c7fd37b6081a418d97632b7d23fe429b3500c4
Author: Iván Ávalos <avalos@disroot.org>
Date:   Tue, 18 Mar 2025 20:13:38 +0100

WIP: getChoicesForPayment op

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-tokens.ts | 35++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/types-taler-wallet.ts | 67++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/common.ts | 2++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/tokenSelection.ts | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 4+++-
Mpackages/taler-wallet-core/src/wallet.ts | 19++++++++++++++++++-
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,