taler-typescript-core

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

commit 1701b6530b4f2a791630e2603b5f7a910c3e83f2
parent 10402e6c45364e94642ad3b6ae88fe64132c6f0c
Author: Iván Ávalos <avalos@disroot.org>
Date:   Fri,  5 Sep 2025 19:17:34 +0200

wallet-core: token family hashing and discount deletion

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-tokens-discount.ts | 24++++++++++++++++++++----
Mpackages/taler-util/src/types-taler-wallet.ts | 17+++++++++++++++++
Mpackages/taler-wallet-core/src/db.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/taler-wallet-core/src/kyc.ts | 10+++++-----
Mpackages/taler-wallet-core/src/pay-merchant.ts | 14++++++++------
Mpackages/taler-wallet-core/src/tokenFamilies.ts | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 9+++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 17++++++++++++++++-
8 files changed, 209 insertions(+), 73 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens-discount.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens-discount.ts @@ -10,7 +10,7 @@ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. - You should have received a copy of the GNU General Public License along with +NU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ @@ -178,8 +178,8 @@ export async function runWalletTokensDiscountTest(t: GlobalTestState) { } } - // reduce discount token family duration to 30 days - tokenFamilyJson.name = "Test discount, but less duration"; + // change name of token family + tokenFamilyJson.name = "Test discount, but different name"; succeedOrThrow<TokenFamilyDetails | void>( await merchantApi.updateTokenFamily( @@ -213,11 +213,27 @@ export async function runWalletTokensDiscountTest(t: GlobalTestState) { t.assertTrue(d1 !== undefined); t.assertTrue(d1.tokensAvailable === 2); - d2 = discounts.find(d => d.name === "Test discount, but less duration"); + d2 = discounts.find(d => d.name === "Test discount, but different name"); t.assertTrue(d2 !== undefined); t.assertTrue(d2.tokensAvailable === 1); } } + + logger.info(`Deleting token family with hash ${d2.tokenFamilyHash}`); + + // delete token family with different name + await walletClient.call( + WalletApiOperation.DeleteDiscount, + { tokenFamilyHash: d2.tokenFamilyHash }, + ); + + const {discounts} = await walletClient.call( + WalletApiOperation.ListDiscounts, + {} + ); + + t.assertTrue(discounts.length === 1); + t.assertTrue(discounts[0].tokensAvailable === 2); } async function createAndPayOrder(req: { diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts @@ -2541,6 +2541,11 @@ export interface ListDiscountsResponse { export interface DiscountListDetail { /** + * Hash of token family info. + */ + tokenFamilyHash: string; + + /** * Hash of token issue public key. */ tokenIssuePubHash: string; @@ -2581,12 +2586,24 @@ export interface DiscountListDetail { tokensAvailable: number; } +export interface DeleteDiscountRequest { + /** + * Hash of token family info. + */ + tokenFamilyHash: string; +} + export const codecForListDiscountsRequest = (): Codec<ListDiscountsRequest> => buildCodecForObject<ListDiscountsRequest>() .property("tokenIssuePubHash", codecOptional(codecForString())) .property("merchantBaseUrl", codecOptional(codecForCanonBaseUrl())) .build("ListDiscounts"); +export const codecForDeleteDiscountRequest = (): Codec<DeleteDiscountRequest> => + buildCodecForObject<DeleteDiscountRequest>() + .property("tokenFamilyHash", codecForString()) + .build("DeleteDiscount"); + export interface CoreApiRequestEnvelope { id: string; operation: string; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -72,8 +72,13 @@ import { WireInfo, WithdrawalExchangeAccountDetails, ZeroLimitedOperation, + bytesToString, + canonicalJson, codecForAny, + encodeCrock, + hash, j2s, + stringToBytes, stringifyScopeInfo, } from "@gnu-taler/taler-util"; import { DbRetryInfo, TaskIdentifiers } from "./common.js"; @@ -965,71 +970,77 @@ export interface CoinRecord { } /** - * TokenFamilyRecord as stored in the "tokenFamilies" - * data store of the wallet database. + * Object to be hashed for use as a grouping key for token listings, such that + * any change in token family details results in a separate list item. */ -export interface TokenRecord { +export interface TokenFamilyInfo { /** - * Source purchase of the token. + * Identifier for the token family consisting of + * unreserved characters according to RFC 3986. */ - purchaseId: string; + slug: string; /** - * Transaction where token is being used. + * Human-readable name for the token family. */ - transactionId?: string; + name: string; /** - * Index of token in choices array. + * Human-readable description for the token family. */ - choiceIndex?: number; + description: string; /** - * Index of token in outputs array. + * Optional map from IETF BCP 47 language tags to localized descriptions. */ - outputIndex?: number; + descriptionI18n: any | undefined; /** - * URL of the merchant issuing the token. + * Additional meta data, such as the trusted_domains + * or expected_domains. Depends on the kind. */ - merchantBaseUrl: string; + extraData: MerchantContractTokenDetails; /** - * Kind of the token. + * Token issue public key used by merchant to verify tokens. */ - kind: MerchantContractTokenKind; + tokenIssuePub: TokenIssuePublicKey; +} +/** + * TokenFamilyRecord as stored in the "tokenFamilies" + * data store of the wallet database. + */ +export interface TokenRecord extends TokenFamilyInfo { /** - * Identifier for the token family consisting of - * unreserved characters according to RFC 3986. + * Source purchase of the token. */ - slug: string; + purchaseId: string; /** - * Human-readable name for the token family. + * Transaction where token is being used. */ - name: string; + transactionId?: string; /** - * Human-readable description for the token family. + * Index of token in choices array. */ - description: string; + choiceIndex?: number; /** - * Optional map from IETF BCP 47 language tags to localized descriptions. + * Index of token in outputs array. */ - descriptionI18n: any | undefined; + outputIndex?: number; /** - * Additional meta data, such as the trusted_domains - * or expected_domains. Depends on the kind. + * URL of the merchant issuing the token. */ - extraData: MerchantContractTokenDetails; + merchantBaseUrl: string; /** - * Token issue public key used by merchant to verify tokens. + * Kind of the token. */ - tokenIssuePub: TokenIssuePublicKey; + kind: MerchantContractTokenKind; /** * Hash of token issue public key. @@ -1037,6 +1048,11 @@ export interface TokenRecord { tokenIssuePubHash: string; /** + * Hash of {@link TokenFamilyInfo} object. + */ + tokenFamilyHash?: string; + + /** * Start time of the token family's validity period. */ validAfter: DbProtocolTimestamp; @@ -1080,7 +1096,7 @@ export interface TokenRecord { * Blinding secret for token. */ blindingKey: string; -} +}; /** * Slate, a blank slice of rock cut for use as a writing surface, @@ -1089,6 +1105,22 @@ export interface TokenRecord { */ export type SlateRecord = Omit<TokenRecord, "tokenIssueSig">; +export namespace TokenRecord { + export function hashInfo(r: TokenRecord | SlateRecord): string { + const info: TokenFamilyInfo = { + slug: r.slug, + name: r.name, + description: r.description, + descriptionI18n: r.descriptionI18n, + extraData: r.extraData, + tokenIssuePub: r.tokenIssuePub, + }; + return encodeCrock( + hash(stringToBytes(canonicalJson(info) + "\0")), + ); + } +} + /** * History item for a coin. * @@ -3026,6 +3058,13 @@ export const WalletStoresV1 = { versionAdded: 17, }, ), + byTokenFamilyHash: describeIndex( + "byTokenFamilyHash", + "tokenFamilyHash", + { + versionAdded: 20, + }, + ), }, ), slates: describeStore( diff --git a/packages/taler-wallet-core/src/kyc.ts b/packages/taler-wallet-core/src/kyc.ts @@ -216,7 +216,7 @@ export function checkWithdrawalHardLimitExceeded( const limitInfo = getWithdrawalLimitInfo(exchange, instructedAmount); return ( limitInfo.kycHardLimit != null && - Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) < 0 ); } @@ -227,7 +227,7 @@ export function checkPeerCreditHardLimitExceeded( const limitInfo = getPeerCreditLimitInfo(exchange, instructedAmount); return ( limitInfo.kycHardLimit != null && - Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) < 0 ); } @@ -238,7 +238,7 @@ export function checkDepositHardLimitExceeded( const limitInfo = getDepositLimitInfo(exchanges, instructedAmount); return ( limitInfo.kycHardLimit != null && - Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) <= 0 + Amounts.cmp(limitInfo.kycHardLimit, instructedAmount) < 0 ); } @@ -296,9 +296,9 @@ export async function checkLimit( // than the previously handled rule (if any). if ( rule.operation_type === operation && - Amounts.cmp(amount, rule.threshold) >= 0 && + Amounts.cmp(amount, rule.threshold) > 0 && (applicableLimit == null || - Amounts.cmp(rule.threshold, applicableLimit.threshold) <= 0) + Amounts.cmp(rule.threshold, applicableLimit.threshold) < 0) ) { applicableLimit = rule; } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -1389,18 +1389,18 @@ async function generateSlate( } const family = contractData.token_families[output.token_family_slug]; - const key = family.keys[output.key_index]; + const tokenIssuePub = family.keys[output.key_index]; const r = await wex.cryptoApi.createSlate({ secretSeed: purchase.secretSeed, choiceIndex: choiceIndex, outputIndex: outputIndex, - tokenIssuePub: key, + tokenIssuePub, genTokenUseSig: true, contractTerms: contractData, contractTermsHash: ContractTermsUtil.hashContractTerms(contractTermsRaw), }); - const newSlate: SlateRecord = { + let newSlate: SlateRecord = { purchaseId: purchase.proposalId, choiceIndex: choiceIndex, outputIndex: outputIndex, @@ -1410,10 +1410,10 @@ async function generateSlate( name: family.name, description: family.description, descriptionI18n: family.description_i18n, - validAfter: timestampProtocolToDb(key.signature_validity_start), - validBefore: timestampProtocolToDb(key.signature_validity_end), extraData: family.details, - tokenIssuePub: r.tokenIssuePub, + tokenIssuePub: tokenIssuePub, + validAfter: timestampProtocolToDb(tokenIssuePub.signature_validity_start), + validBefore: timestampProtocolToDb(tokenIssuePub.signature_validity_end), tokenIssuePubHash: r.tokenIssuePubHash, tokenUsePub: r.tokenPub, tokenUsePriv: r.tokenPriv, @@ -1422,6 +1422,8 @@ async function generateSlate( blindingKey: r.blindingKey, }; + newSlate.tokenFamilyHash = TokenRecord.hashInfo(newSlate); + await wex.db.runReadWriteTx({ storeNames: ["slates"] }, async (tx) => { const s = await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get([ diff --git a/packages/taler-wallet-core/src/tokenFamilies.ts b/packages/taler-wallet-core/src/tokenFamilies.ts @@ -20,53 +20,55 @@ * @author Iván Ávalos */ import { - canonicalJson, DiscountListDetail, + EmptyObject, ListDiscountsResponse, Logger, - TokenIssuePublicKey, } from "@gnu-taler/taler-util"; import { WalletExecutionContext } from "./index.js"; import { TokenRecord } from "./db.js"; import { + isTokenInUse, isTokenValid, } from "./tokenSelection.js"; const logger = new Logger("tokenFamilies.ts"); // FIXME: unit test discount grouping -function groupDiscounts(tokens: { - name: string, - description: string, - descriptionI18n: any | undefined, - merchantBaseUrl: string, - tokenIssuePub: TokenIssuePublicKey, - tokenIssuePubHash: string, -}[]): DiscountListDetail[] { +function groupDiscounts(tokens: TokenRecord[]): DiscountListDetail[] { const groupedIdx: number[] = []; const items: DiscountListDetail[] = []; + // FIXME: write proper DB fixup to generate hashes + tokens = tokens.map(t => { + t.tokenFamilyHash = (t.tokenFamilyHash) + ? t.tokenFamilyHash + : TokenRecord.hashInfo(t); + return t; + }); + // compare tokens against each other, // except the ones already in a group. for (let a = 0; a < tokens.length; a++) { if (groupedIdx.includes(a)) continue; - const tokenA = tokens[a]; + let tokenA = tokens[a]; const item: DiscountListDetail = { - ...tokenA, - tokensAvailable: 1, + tokenFamilyHash: tokenA.tokenFamilyHash!, + tokenIssuePubHash: tokenA.tokenIssuePubHash, + merchantBaseUrl: tokenA.merchantBaseUrl, + name: tokenA.name, + description: tokenA.description, + descriptionI18n: tokenA.descriptionI18n, validityStart: tokenA.tokenIssuePub.signature_validity_start, validityEnd: tokenA.tokenIssuePub.signature_validity_end, + tokensAvailable: 1, }; for (let b = 0; b < tokens.length; b++) { if (b === a) continue; if (groupedIdx.includes(b)) continue; - const tokenB = tokens[b]; - const fields = Object.keys(tokenB) as (keyof typeof tokenB)[]; - const equal = fields.every(f => - canonicalJson(tokenA[f]) === canonicalJson(tokenB[f]) - ); - if (equal) { + let tokenB = tokens[b]; + if (tokenA.tokenFamilyHash === tokenB.tokenFamilyHash) { item.tokensAvailable += 1; groupedIdx.push(b); } @@ -98,13 +100,49 @@ export async function listDiscounts( } return { - discounts: groupDiscounts(tokens.map(t => ({ - name: t.name, - description: t.description, - descriptionI18n: t.descriptionI18n, - merchantBaseUrl: t.merchantBaseUrl, - tokenIssuePub: t.tokenIssuePub, - tokenIssuePubHash: t.tokenIssuePubHash, - }))), + discounts: groupDiscounts(tokens), }; } + +export async function deleteDiscount( + wex: WalletExecutionContext, + tokenFamilyHash: string, +): Promise<EmptyObject> { + await wex.db.runReadWriteTx({ + storeNames: ["tokens"], + }, async (tx) => { + const tokens = (await tx.tokens.getAll()) + .map(t => { + // FIXME: write proper DB fixup to generate hashes + t.tokenFamilyHash = (t.tokenFamilyHash) + ? t.tokenFamilyHash + : TokenRecord.hashInfo(t); + return t; + }) + .filter(t => t.tokenFamilyHash === tokenFamilyHash); + + let inUse: boolean = false; + for (const token of tokens) { + if (isTokenInUse(token)) { + inUse = true; + return; + } + } + + // FIXME: proper GANA error + if (inUse) { + throw Error("One or more tokens in this family are in use"); + } + + for (const token of tokens) { + if (logger.shouldLogTrace()) { + logger.trace( + `deleting token in ${token.tokenIssuePubHash} token family` + ); + } + await tx.tokens.delete(token.tokenUsePub); + } + }); + + return {}; +} diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -65,6 +65,7 @@ import { CreateDepositGroupRequest, CreateDepositGroupResponse, CreateStoredBackupResponse, + DeleteDiscountRequest, DeleteExchangeRequest, DeleteStoredBackupRequest, DeleteTransactionRequest, @@ -292,6 +293,7 @@ export enum WalletApiOperation { // Tokens and token families ListDiscounts = "listDiscounts", + DeleteDiscount = "deleteDiscount", // Donau SetDonau = "setDonau", @@ -718,6 +720,12 @@ export type ListDiscountsOp = { response: ListDiscountsResponse; }; +export type DeleteDiscountOp = { + op: WalletApiOperation.DeleteDiscount; + request: DeleteDiscountRequest; + response: EmptyObject; +}; + // group: Global Currency management export type ListGlobalCurrencyAuditorsOp = { @@ -1591,6 +1599,7 @@ export type WalletOperations = { [WalletApiOperation.GetQrCodesForPayto]: GetQrCodesForPaytoOp; [WalletApiOperation.GetBankingChoicesForPayto]: GetBankingChoicesForPaytoOp; [WalletApiOperation.ListDiscounts]: ListDiscountsOp; + [WalletApiOperation.DeleteDiscount]: DeleteDiscountOp; [WalletApiOperation.StartExchangeWalletKyc]: StartExchangeWalletKycOp; [WalletApiOperation.TestingWaitExchangeWalletKyc]: TestingWaitExchangeWalletKycOp; [WalletApiOperation.TestingPlanMigrateExchangeBaseUrl]: TestingPlanMigrateExchangeBaseUrlOp; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -58,6 +58,7 @@ import { ConfirmPayResult, CoreApiResponse, CreateStoredBackupResponse, + DeleteDiscountRequest, DeleteExchangeRequest, DeleteStoredBackupRequest, DenominationInfo, @@ -166,6 +167,7 @@ import { codecForConfirmWithdrawalRequestRequest, codecForConvertAmountRequest, codecForCreateDepositGroupRequest, + codecForDeleteDiscountRequest, codecForDeleteExchangeRequest, codecForDeleteStoredBackupRequest, codecForDeleteTransactionRequest, @@ -417,7 +419,7 @@ import { getWithdrawalDetailsForUri, prepareBankIntegratedWithdrawal, } from "./withdraw.js"; -import { listDiscounts } from "./tokenFamilies.js"; +import { deleteDiscount, listDiscounts } from "./tokenFamilies.js"; const logger = new Logger("wallet.ts"); @@ -1281,6 +1283,15 @@ async function handleListDiscounts( ); } +async function handleDeleteDiscount( + wex: WalletExecutionContext, + req: DeleteDiscountRequest, +): Promise<EmptyObject> { + return await deleteDiscount(wex, + req.tokenFamilyHash, + ); +} + async function handleAbortTransaction( wex: WalletExecutionContext, req: AbortTransactionRequest, @@ -2134,6 +2145,10 @@ const handlers: { [T in WalletApiOperation]: HandlerWithValidator<T> } = { codec: codecForListDiscountsRequest(), handler: handleListDiscounts, }, + [WalletApiOperation.DeleteDiscount]: { + codec: codecForDeleteDiscountRequest(), + handler: handleDeleteDiscount, + }, [WalletApiOperation.SuspendTransaction]: { codec: codecForSuspendTransaction(), handler: handleSuspendTransaction,