taler-typescript-core

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

commit bad14bd70143aaa680bde62fa97e5f941e6284a2
parent 6c81a0791cf3f2e068cf53e52f7dd88c21d8ee0f
Author: Iván Ávalos <avalos@disroot.org>
Date:   Mon, 30 Jun 2025 19:09:18 +0200

wallet-core: fixes and improvements to token selection

Diffstat:
Mpackages/taler-wallet-core/src/pay-merchant.ts | 17+++++++----------
Mpackages/taler-wallet-core/src/tokenSelection.ts | 135+++++++++++++++++++++++++++++++++++++------------------------------------------
2 files changed, 71 insertions(+), 81 deletions(-)

diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -3358,6 +3358,7 @@ async function processPurchasePay( const slatesLen = slates?.length ?? 0; const sigsLen = merchantResp.token_sigs?.length ?? 0; + logger.trace(`received ${sigsLen} token signatures from merchant`); if (slatesLen !== sigsLen) { throw Error( `merchant returned mismatching number of token signatures (${slatesLen} vs ${sigsLen})`, @@ -3370,8 +3371,8 @@ async function processPurchasePay( } } - if (purchase.choiceIndex) { - await cleanupUsedTokens(wex, purchase.proposalId, purchase.choiceIndex); + if (payInfo.payTokenSelection?.tokenPubs) { + await cleanupUsedTokens(wex, payInfo.payTokenSelection.tokenPubs); } await storeFirstPaySuccess(wex, proposalId, sessionId, merchantResp); @@ -3436,6 +3437,8 @@ export async function validateAndStoreToken( throw Error("token issue signature invalid"); } + logger.trace(`token ${tokenIssuePubHash} for purchase ${slate.purchaseId} is valid, will be stored`); + const token: TokenRecord = { tokenIssueSig, ...slate, @@ -3514,21 +3517,15 @@ export async function generateTokenSigs( export async function cleanupUsedTokens( wex: WalletExecutionContext, - proposalId: string, - choiceIndex: number, + tokenPubs: string[], ): Promise<void> { await wex.db.runReadWriteTx( { storeNames: ["tokens"], }, async (tx) => { - const tokenPubs = - await tx.tokens.indexes.byPurchaseIdAndChoiceIndex.getAllKeys([ - proposalId, - choiceIndex, - ]); - for (const pub of tokenPubs) { + logger.trace(`cleaning up used token ${pub}`); tx.tokens.delete(pub); } }, diff --git a/packages/taler-wallet-core/src/tokenSelection.ts b/packages/taler-wallet-core/src/tokenSelection.ts @@ -20,11 +20,9 @@ import { hashTokenIssuePub, j2s, Logger, - MerchantContractInput, MerchantContractInputType, MerchantContractTermsV1, MerchantContractTokenDetails, - MerchantContractTokenFamily, MerchantContractTokenKind, PaymentTokenAvailabilityDetails, } from "@gnu-taler/taler-util"; @@ -179,30 +177,33 @@ export async function selectPayTokensInTx( } const inputs = req.contractTerms.choices[req.choiceIndex].inputs; - const tokenFamilies = req.contractTerms.token_families; + const inputTokens: {[tokenIssuePubHash: string]: TokenRecord[]} = {}; + const inputCounts: {[tokenIssuePubHash: string]: number} = {}; + var tokensRequested = 0; - const inputTokens: {[slug: string]: TokenRecord[]} = {}; for (const input of inputs) { if (input.type == MerchantContractInputType.Token) { const slug = input.token_family_slug; const keys = req.contractTerms.token_families[slug].keys; + const count = input.count ?? 1; + tokensRequested += count; for (const key of keys) { const keyHash = encodeCrock(hashTokenIssuePub(key)); const t = await tx.tokens.indexes.byTokenIssuePubHash.getAll(keyHash); - if (inputTokens[slug]) { - inputTokens[slug].push(...t); - } else { - inputTokens[slug] = t; + if (inputTokens[keyHash] === undefined) { + logger.trace(`found total of ${t.length} tokens for token family ${keyHash}`); + inputCounts[keyHash] = count; + inputTokens[keyHash] = t; } } } } return selectTokenCandidates( - inputs, - tokenFamilies, inputTokens, + inputCounts, + tokensRequested, proposal.merchantBaseUrl, ); } @@ -225,13 +226,13 @@ export async function selectPayTokens( } export function selectTokenCandidates( - inputs: MerchantContractInput[], - tokenFamilies: {[slug: string]: MerchantContractTokenFamily}, - inputTokens: {[slug: string]: TokenRecord[]}, + inputTokens: {[tokenIssuePubHash: string]: TokenRecord[]}, + inputCounts: {[tokenIssuePubHash: string]: number}, + tokensRequested: number, merchantBaseUrl: string, ): SelectPayTokensResult { const details: PaymentTokenAvailabilityDetails = { - tokensRequested: 0, + tokensRequested, tokensAvailable: 0, tokensUnexpected: 0, tokensUntrusted: 0, @@ -241,68 +242,60 @@ export function selectTokenCandidates( var insufficient = false; const records: TokenRecord[] = []; - for (const input of inputs) { - if (input.type == MerchantContractInputType.Token) { - const count = input.count ?? 1; - const slug = input.token_family_slug; - const tokens = inputTokens[slug]; - details.tokensRequested += count; + for (const keyHash in inputTokens) { + const tokens = inputTokens[keyHash]; + const count = inputCounts[keyHash]; - for (const key of tokenFamilies[slug].keys) { - const keyHash = encodeCrock(hashTokenIssuePub(key)); - if (details.perTokenFamily[keyHash] === undefined) { - details.perTokenFamily[keyHash] = { - requested: count, - available: 0, - unexpected: 0, - untrusted: 0, - } + details.perTokenFamily[keyHash] = { + requested: count, + available: 0, + unexpected: 0, + untrusted: 0, + }; + + // 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 n tokens in the list + const usable = tokens.filter(tok => + !tok.transactionId && AbsoluteTime.isBetween( + AbsoluteTime.now(), + AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)), + AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)), + )).filter(tok => { + const res = verifyTokenMerchant( + merchantBaseUrl, + tok.merchantBaseUrl, + tok.extraData, + ); + switch (res) { + case TokenMerchantVerificationResult.Automatic: + details.perTokenFamily[keyHash].available += 1; + return true; // usable + case TokenMerchantVerificationResult.Unexpected: + details.tokensUnexpected += 1; + details.perTokenFamily[keyHash].unexpected += 1; + details.perTokenFamily[keyHash].available += 1; + return true; // usable + case TokenMerchantVerificationResult.Untrusted: + details.tokensUntrusted += 1; + details.perTokenFamily[keyHash].untrusted += 1; + return false; // non-usable + default: + assertUnreachable(res); } - } + }).sort((a, b) => a.validBefore - b.validBefore); - // 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 n tokens in the list - const usable = tokens.filter(tok => - !tok.transactionId && AbsoluteTime.isBetween( - AbsoluteTime.now(), - AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validAfter)), - AbsoluteTime.fromProtocolTimestamp(timestampProtocolFromDb(tok.validBefore)), - )).filter(tok => { - const res = verifyTokenMerchant( - merchantBaseUrl, - tok.merchantBaseUrl, - tok.extraData, - ); - switch (res) { - case TokenMerchantVerificationResult.Automatic: - details.perTokenFamily[tok.tokenIssuePubHash].available += 1; - return true; // usable - case TokenMerchantVerificationResult.Unexpected: - details.tokensUnexpected += 1; - details.perTokenFamily[tok.tokenIssuePubHash].unexpected += 1; - details.perTokenFamily[tok.tokenIssuePubHash].available += 1; - return true; // usable - case TokenMerchantVerificationResult.Untrusted: - details.tokensUntrusted += 1; - details.perTokenFamily[tok.tokenIssuePubHash].untrusted += 1; - return false; // non-usable - default: - assertUnreachable(res); - } - }).sort((a, b) => a.validBefore - b.validBefore); - - if (usable.length < count) { - insufficient = true; - continue; - } + details.tokensAvailable += details.perTokenFamily[keyHash].available; - details.tokensAvailable += count; - records.push(...usable.slice(0, count)); + if (usable.length < count) { + insufficient = true; + continue; } + + records.push(...usable.slice(0, count)); } if (insufficient) {