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:
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);
}
});
}