taler-typescript-core

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

commit 82746164481154de96e449484a8fae989d37b461
parent 6cd293b2805637ba315861fc5a8eabda8174ed3c
Author: Florian Dold <dold@taler.net>
Date:   Sat, 18 Jul 2026 22:08:03 +0200

db: make WalletToken the full record, add WalletSlate

Diffstat:
Mpackages/taler-wallet-core/src/common.ts | 3++-
Mpackages/taler-wallet-core/src/db-common.ts | 150++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mpackages/taler-wallet-core/src/db-indexeddb.ts | 166+++----------------------------------------------------------------------------
Mpackages/taler-wallet-core/src/dbtx-indexeddb.ts | 104++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/taler-wallet-core/src/dbtx.ts | 36+++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 56++++++++++++++++++++++++++++++--------------------------
6 files changed, 253 insertions(+), 262 deletions(-)

diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -291,7 +291,8 @@ export async function spendTokens( } logger.trace(`spending token ${token.tokenUsePub} in ${tsi.transactionId}`); - await tx.setTokenTransactionId(token.tokenUsePub, tsi.transactionId); + token.transactionId = tsi.transactionId; + await tx.upsertToken(token); } } diff --git a/packages/taler-wallet-core/src/db-common.ts b/packages/taler-wallet-core/src/db-common.ts @@ -50,6 +50,11 @@ import { CoinStatus, AgeCommitmentProof, TransactionIdStr, + TokenEnvelope, + encodeCrock, + hash, + stringToBytes, + canonicalJson, } from "@gnu-taler/taler-util"; declare const symDbProtocolTimestamp: unique symbol; @@ -2392,9 +2397,10 @@ export interface WalletPeerPullDebit { } /** - * Token record. + * 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 WalletToken { +export interface TokenFamilyInfo { /** * Identifier for the token family consisting of * unreserved characters according to RFC 3986. @@ -2426,6 +2432,16 @@ export interface WalletToken { * Token issue public key used by merchant to verify tokens. */ tokenIssuePub: TokenIssuePublicKey; +} + +/** + * A token as stored in the "tokens" object store. + * + * This is the full stored shape: it carries the blinding material + * (tokenEv, tokenEvHash, blindingKey) alongside the issue signature, so a + * read-modify-write round trip through it is lossless. + */ +export interface WalletToken extends TokenFamilyInfo { /** * Source purchase of the token. @@ -2472,7 +2488,7 @@ export interface WalletToken { tokenIssuePubHash: string; /** - * Hash of TokenFamilyInfo object. + * Hash of {@link TokenFamilyInfo} object. */ tokenFamilyHash?: string; @@ -2505,8 +2521,134 @@ export interface WalletToken { * Signature on token use request. */ tokenUseSig?: TokenUseSig; -} + /** + * Envelope of the token. + */ + tokenEv: TokenEnvelope; + + /** + * Hash of the envelope. + */ + tokenEvHash: string; + + /** + * Blinding secret for token. + */ + blindingKey: string;} + +/** + * A slate as stored in the "slates" object store. + * + * A slate is a token that has not been issued yet, so it has every token + * field except tokenIssueSig. It is spelled out rather than derived from + * WalletToken so that each store has its own record type. + */ +export interface WalletSlate extends TokenFamilyInfo { + + /** + * Source purchase of the token. + */ + purchaseId: string; + + /** + * Transaction where token is being used. + */ + transactionId?: string; + + /** + * Index of token in choices array. + */ + choiceIndex?: number; + + /** + * Index of token in outputs array. + */ + outputIndex?: number; + + /** + * For token outputs with a count>1, this stores + * the index of this token within the same output + * index. + * + * If missing, assumed to be 0. + */ + repeatIndex?: number; + + /** + * URL of the merchant issuing the token. + */ + merchantBaseUrl: string; + + /** + * Kind of the token. + */ + kind: MerchantContractTokenKind; + + /** + * Hash of token issue public key. + */ + tokenIssuePubHash: string; + + /** + * Hash of {@link TokenFamilyInfo} object. + */ + tokenFamilyHash?: string; + + /** + * Start time of the token family's validity period. + */ + validAfter: DbProtocolTimestamp; + + /** + * End time of the token family's validity period. + */ + validBefore: DbProtocolTimestamp; + + + /** + * Token use public key used to confirm usage of tokens. + */ + tokenUsePub: string; + + /** + * Token use private key used to verify usage of tokens. + */ + tokenUsePriv: string; + + /** + * Signature on token use request. + */ + tokenUseSig?: TokenUseSig; + + /** + * Envelope of the token. + */ + tokenEv: TokenEnvelope; + + /** + * Hash of the envelope. + */ + tokenEvHash: string; + + /** + * Blinding secret for token. + */ + blindingKey: string;} + +export namespace WalletToken { + export function hashInfo(r: WalletToken | WalletSlate): 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"))); + } +} /** * Denomination record as stored in the wallet's database. */ diff --git a/packages/taler-wallet-core/src/db-indexeddb.ts b/packages/taler-wallet-core/src/db-indexeddb.ts @@ -141,6 +141,9 @@ import { WalletExchangeDetailsPointer, WalletExchangeEntry, WalletExchangeDetails, + WalletSlate, + WalletToken, + TokenFamilyInfo, WalletRefundGroup, WalletRefundItem, WalletTombstone, @@ -286,165 +289,6 @@ export interface ExchangeSignkeysRecord { exchangeDetailsRowId: number; } -/** - * 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 TokenFamilyInfo { - /** - * Identifier for the token family consisting of - * unreserved characters according to RFC 3986. - */ - slug: string; - - /** - * Human-readable name for the token family. - */ - name: string; - - /** - * Human-readable description for the token family. - */ - description: string; - - /** - * Optional map from IETF BCP 47 language tags to localized descriptions. - */ - descriptionI18n: any | undefined; - - /** - * Additional meta data, such as the trusted_domains - * or expected_domains. Depends on the kind. - */ - extraData: MerchantContractTokenDetails; - - /** - * Token issue public key used by merchant to verify tokens. - */ - tokenIssuePub: TokenIssuePublicKey; -} - -/** - * TokenFamilyRecord as stored in the "tokenFamilies" - * data store of the wallet database. - */ -export interface TokenRecord extends TokenFamilyInfo { - /** - * Source purchase of the token. - */ - purchaseId: string; - - /** - * Transaction where token is being used. - */ - transactionId?: string; - - /** - * Index of token in choices array. - */ - choiceIndex?: number; - - /** - * Index of token in outputs array. - */ - outputIndex?: number; - - /** - * For token outputs with a count>1, this stores - * the index of this token within the same output - * index. - * - * If missing, assumed to be 0. - */ - repeatIndex?: number; - - /** - * URL of the merchant issuing the token. - */ - merchantBaseUrl: string; - - /** - * Kind of the token. - */ - kind: MerchantContractTokenKind; - - /** - * Hash of token issue public key. - */ - tokenIssuePubHash: string; - - /** - * Hash of {@link TokenFamilyInfo} object. - */ - tokenFamilyHash?: string; - - /** - * Start time of the token family's validity period. - */ - validAfter: DbProtocolTimestamp; - - /** - * End time of the token family's validity period. - */ - validBefore: DbProtocolTimestamp; - - /** - * Unblinded token issue signature made by the merchant. - */ - tokenIssueSig: UnblindedDenominationSignature; - - /** - * Token use public key used to confirm usage of tokens. - */ - tokenUsePub: string; - - /** - * Token use private key used to verify usage of tokens. - */ - tokenUsePriv: string; - - /** - * Signature on token use request. - */ - tokenUseSig?: TokenUseSig; - - /** - * Envelope of the token. - */ - tokenEv: TokenEnvelope; - - /** - * Hash of the envelope. - */ - tokenEvHash: string; - - /** - * Blinding secret for token. - */ - blindingKey: string; -} - -/** - * Slate, a blank slice of rock cut for use as a writing surface, - * also the database representation of a token before being - * signed by the merchant, as stored in the `slates' data store. - */ -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"))); - } -} - // FIXME: Should these be numeric codes? export type KycUserType = "individual" | "business"; @@ -956,7 +800,7 @@ export const WalletIndexedDbStoresV1 = { ), tokens: describeStore( "tokens", - describeContents<TokenRecord>({ + describeContents<WalletToken>({ keyPath: "tokenUsePub", versionAdded: 16, }), @@ -982,7 +826,7 @@ export const WalletIndexedDbStoresV1 = { ), slates: describeStore( "slates", - describeContents<SlateRecord>({ + describeContents<WalletSlate>({ keyPath: "tokenUsePub", versionAdded: 16, }), diff --git a/packages/taler-wallet-core/src/dbtx-indexeddb.ts b/packages/taler-wallet-core/src/dbtx-indexeddb.ts @@ -44,6 +44,7 @@ import { WalletPeerPushCredit, WalletPeerPullDebit, WalletToken, + WalletSlate, WalletDenomination, WalletTransactionMeta, DbPreciseTimestamp, @@ -466,6 +467,44 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await tx.refundItems.indexes.byCoinPubAndRtxid.get([coinPub, rtxid]); } + async getSlate( + purchaseId: string, + choiceIndex: number, + outputIndex: number, + repeatIndex: number, + ): Promise<WalletSlate | undefined> { + const tx = this.tx; + return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get( + [purchaseId, choiceIndex, outputIndex, repeatIndex], + ); + } + + async getSlatesByPurchaseAndChoice( + purchaseId: string, + choiceIndex: number, + ): Promise<WalletSlate[]> { + const tx = this.tx; + return await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll([ + purchaseId, + choiceIndex, + ]); + } + + async listSlates(): Promise<WalletSlate[]> { + const tx = this.tx; + return await tx.slates.getAll(); + } + + async upsertSlate(rec: WalletSlate): Promise<void> { + const tx = this.tx; + await tx.slates.put(rec); + } + + async deleteSlate(tokenUsePub: string): Promise<void> { + const tx = this.tx; + await tx.slates.delete(tokenUsePub); + } + async upsertTombstone(rec: WalletTombstone): Promise<void> { const tx = this.tx; await tx.tombstones.put(rec); @@ -779,30 +818,7 @@ export class IdbWalletTransaction implements WalletDbTransaction { async listTokens(): Promise<WalletToken[]> { const tx = this.tx; - const records = await tx.tokens.getAll(); - return records.map((r) => ({ - slug: r.slug, - name: r.name, - description: r.description, - descriptionI18n: r.descriptionI18n, - extraData: r.extraData, - tokenIssuePub: r.tokenIssuePub, - purchaseId: r.purchaseId, - transactionId: r.transactionId, - choiceIndex: r.choiceIndex, - outputIndex: r.outputIndex, - repeatIndex: r.repeatIndex, - merchantBaseUrl: r.merchantBaseUrl, - kind: r.kind, - tokenIssuePubHash: r.tokenIssuePubHash, - tokenFamilyHash: r.tokenFamilyHash, - validAfter: r.validAfter, - validBefore: r.validBefore, - tokenIssueSig: r.tokenIssueSig, - tokenUsePub: r.tokenUsePub, - tokenUsePriv: r.tokenUsePriv, - tokenUseSig: r.tokenUseSig, - })); + return await tx.tokens.getAll(); } async getToken(tokenUsePub: string): Promise<WalletToken | undefined> { @@ -810,17 +826,9 @@ export class IdbWalletTransaction implements WalletDbTransaction { return await tx.tokens.get(tokenUsePub); } - async setTokenTransactionId( - tokenUsePub: string, - transactionId: TransactionIdStr, - ): Promise<void> { + async upsertToken(token: WalletToken): Promise<void> { const tx = this.tx; - const rec = await tx.tokens.get(tokenUsePub); - if (!rec) { - throw Error("token not found"); - } - rec.transactionId = transactionId; - await tx.tokens.put(rec); + await tx.tokens.put(token); } async deleteToken(tokenUsePub: string): Promise<void> { @@ -832,31 +840,9 @@ export class IdbWalletTransaction implements WalletDbTransaction { tokenIssuePubHash: string, ): Promise<WalletToken[]> { const tx = this.tx; - const records = - await tx.tokens.indexes.byTokenIssuePubHash.getAll(tokenIssuePubHash); - return records.map((r) => ({ - slug: r.slug, - name: r.name, - description: r.description, - descriptionI18n: r.descriptionI18n, - extraData: r.extraData, - tokenIssuePub: r.tokenIssuePub, - purchaseId: r.purchaseId, - transactionId: r.transactionId, - choiceIndex: r.choiceIndex, - outputIndex: r.outputIndex, - repeatIndex: r.repeatIndex, - merchantBaseUrl: r.merchantBaseUrl, - kind: r.kind, - tokenIssuePubHash: r.tokenIssuePubHash, - tokenFamilyHash: r.tokenFamilyHash, - validAfter: r.validAfter, - validBefore: r.validBefore, - tokenIssueSig: r.tokenIssueSig, - tokenUsePub: r.tokenUsePub, - tokenUsePriv: r.tokenUsePriv, - tokenUseSig: r.tokenUseSig, - })); + return await tx.tokens.indexes.byTokenIssuePubHash.getAll( + tokenIssuePubHash, + ); } async getPeerPullCredit( diff --git a/packages/taler-wallet-core/src/dbtx.ts b/packages/taler-wallet-core/src/dbtx.ts @@ -46,6 +46,7 @@ import { WalletPeerPushCredit, WalletPeerPullDebit, WalletToken, + WalletSlate, WalletDenomination, WalletTransactionMeta, DbPreciseTimestamp, @@ -288,6 +289,27 @@ export interface WalletDbTransaction { rtxid: number, ): Promise<WalletRefundItem | undefined>; + /** + * Get a slate by purchase, choice, output and repeat index. + */ + getSlate( + purchaseId: string, + choiceIndex: number, + outputIndex: number, + repeatIndex: number, + ): Promise<WalletSlate | undefined>; + + getSlatesByPurchaseAndChoice( + purchaseId: string, + choiceIndex: number, + ): Promise<WalletSlate[]>; + + listSlates(): Promise<WalletSlate[]>; + + upsertSlate(rec: WalletSlate): Promise<void>; + + deleteSlate(tokenUsePub: string): Promise<void>; + upsertTombstone(rec: WalletTombstone): Promise<void>; getDonationSummary( @@ -456,17 +478,9 @@ export interface WalletDbTransaction { getToken(tokenUsePub: string): Promise<WalletToken | undefined>; /** - * Mark a token as spent in a transaction. - * - * This is a targeted update rather than an upsert of a whole WalletToken, - * because WalletToken is a narrower projection of the stored record: it does - * not carry blindingKey, tokenEv or tokenEvHash, so a read-modify-write - * through it would silently drop them. - */ - setTokenTransactionId( - tokenUsePub: string, - transactionId: TransactionIdStr, - ): Promise<void>; + * Create or update a wallet token. + */ + upsertToken(token: WalletToken): Promise<void>; /** * Delete a wallet token by its token use public key. diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -157,11 +157,11 @@ import { WalletPurchase, WalletRefundGroup, WalletRefundItem, + WalletSlate, + WalletToken, } from "./db-common.js"; import { RefundReason, - SlateRecord, - TokenRecord, } from "./db-indexeddb.js"; import { acceptDonauBlindSigs, generateDonauPlanchets } from "./donau.js"; import { @@ -1272,8 +1272,11 @@ async function generateSlate( ); let slate = await wex.runLegacyWalletDbTx(async (tx) => { - return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get( - [purchase.proposalId, choiceIndex, outputIndex, repeatIndex], + return await tx.wtx.getSlate( + purchase.proposalId, + choiceIndex, + outputIndex, + repeatIndex, ); }); @@ -1300,7 +1303,7 @@ async function generateSlate( repeatIndex, }); - const newSlate: SlateRecord = { + const newSlate: WalletSlate = { purchaseId: purchase.proposalId, choiceIndex: choiceIndex, outputIndex: outputIndex, @@ -1323,17 +1326,19 @@ async function generateSlate( repeatIndex, }; - newSlate.tokenFamilyHash = TokenRecord.hashInfo(newSlate); + newSlate.tokenFamilyHash = WalletToken.hashInfo(newSlate); await wex.runLegacyWalletDbTx(async (tx) => { - const s = - await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get( - [purchase.proposalId, choiceIndex, outputIndex, repeatIndex], - ); + const s = await tx.wtx.getSlate( + purchase.proposalId, + choiceIndex, + outputIndex, + repeatIndex, + ); if (s) { return; } - await tx.slates.put(newSlate); + await tx.wtx.upsertSlate(newSlate); }); } @@ -3258,7 +3263,7 @@ async function processPurchasePay( download.contractTerms.merchant_base_url, ); - let slates: SlateRecord[] | undefined = undefined; + let slates: WalletSlate[] | undefined = undefined; let donauPlanchets: WalletDonationPlanchet[] | undefined = undefined; let wallet_data: PayWalletData | undefined = undefined; if ( @@ -3271,13 +3276,12 @@ async function processPurchasePay( slates = []; wallet_data = { choice_index: index, tokens_evs: [] }; await wex.runLegacyWalletDbTx(async (tx) => { - const allSlateRes = await tx.slates.getAll(); + const allSlateRes = await tx.wtx.listSlates(); logger.info(`all slates: ${j2s(allSlateRes)}`); - const slateRes = - await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll([ - purchase.proposalId, - index, - ]); + const slateRes = await tx.wtx.getSlatesByPurchaseAndChoice( + purchase.proposalId, + index, + ); logger.info( `got ${slateRes.length} slates from DB, req=${[ purchase.proposalId, @@ -3572,7 +3576,7 @@ async function processPurchasePay( export async function validateAndStoreToken( wex: WalletExecutionContext, - slate: SlateRecord, + slate: WalletSlate, blindedEv: SignedTokenEnvelope, ): Promise<void> { const { tokenIssuePub, tokenIssuePubHash, tokenUsePub, blindingKey } = slate; @@ -3609,15 +3613,15 @@ export async function validateAndStoreToken( `token ${tokenIssuePubHash} for purchase ${slate.purchaseId} is valid, will be stored`, ); - const token: TokenRecord = { + const token: WalletToken = { tokenIssueSig, ...slate, }; // insert token and delete slate await wex.runLegacyWalletDbTx(async (tx) => { - await tx.tokens.add(token); - await tx.slates.delete(slate.tokenUsePub); + await tx.wtx.upsertToken(token); + await tx.wtx.deleteSlate(slate.tokenUsePub); }); } @@ -3628,11 +3632,11 @@ export async function generateTokenSigs( walletDataHash: string, tokenPubs: string[], ): Promise<TokenUseSig[]> { - const tokens: TokenRecord[] = []; + const tokens: WalletToken[] = []; const sigs: TokenUseSig[] = []; await wex.runLegacyWalletDbTx(async (tx) => { for (const pub of tokenPubs) { - const token = await tx.tokens.get(pub); + const token = await tx.wtx.getToken(pub); checkDbInvariant(!!token, `token not found for ${pub}`); tokens.push(token); } @@ -3663,7 +3667,7 @@ export async function generateTokenSigs( const token = tokens[i]; const sig = sigs[i]; token.tokenUseSig = sig; - tx.tokens.put(token); + await tx.wtx.upsertToken(token); } }); @@ -3677,7 +3681,7 @@ export async function cleanupUsedTokens( await wex.runLegacyWalletDbTx(async (tx) => { for (const pub of tokenPubs) { logger.trace(`cleaning up used token ${pub}`); - tx.tokens.delete(pub); + await tx.wtx.deleteToken(pub); } }); }