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:
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,