commit 1fa6532137d4c4666baf0def8f5b8dfa88cae99b
parent d7ab425239226b798a95dbf6742fe4b4a2e30e47
Author: Florian Dold <florian@dold.me>
Date: Tue, 7 Jul 2026 16:39:01 +0200
wallet-core: support output tokenss with count>1
Diffstat:
5 files changed, 136 insertions(+), 26 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts
@@ -21,6 +21,7 @@ import {
AbsoluteTime,
ChoiceSelectionDetailType,
Duration,
+ j2s,
Order,
OrderInputType,
OrderOutputType,
@@ -30,6 +31,7 @@ import {
TalerProtocolTimestamp,
TokenAvailabilityHint,
TokenFamilyKind,
+ TransactionMajorState,
} from "@gnu-taler/taler-util";
import { WalletApiOperation } from "@gnu-taler/taler-wallet-core";
import { defaultCoinConfig } from "../harness/denomStructures.js";
@@ -52,8 +54,7 @@ export async function runWalletTokensTest(t: GlobalTestState) {
defaultCoinConfig.map((x) => x("TESTKUDOS")),
{
walletConfig: {
- features: {
- },
+ features: {},
},
},
);
@@ -117,7 +118,7 @@ export async function runWalletTokensTest(t: GlobalTestState) {
}),
);
- let orderJsonDiscount: Order = {
+ const orderJsonDiscount: Order = {
version: 1,
summary: "Test order",
timestamp: TalerProtocolTimestamp.now(),
@@ -148,6 +149,18 @@ export async function runWalletTokensTest(t: GlobalTestState) {
],
outputs: [],
},
+ // Multiple outputs
+ {
+ amount: "TESTKUDOS:3",
+ inputs: [],
+ outputs: [
+ {
+ count: 5,
+ type: OrderOutputType.Token,
+ token_family_slug: "test_discount",
+ },
+ ],
+ },
],
};
@@ -279,7 +292,9 @@ export async function runWalletTokensTest(t: GlobalTestState) {
},
);
- t.assertTrue(choicesRes.defaultChoiceIndex === 1);
+ console.log(j2s(choicesRes));
+
+ t.assertDeepEqual(choicesRes.defaultChoiceIndex, 1);
t.assertTrue(choicesRes.automaticExecution === false);
t.assertTrue(choicesRes.automaticExecutableIndex === undefined);
@@ -574,6 +589,69 @@ export async function runWalletTokensTest(t: GlobalTestState) {
await merchantApi.getOrderDetails(merchantAdminAccessToken, orderId),
);
}
+
+ // Multiple inputs/outputs for discount tokens.
+ {
+ const orderResp = succeedOrThrow(
+ await merchantApi.createOrder(merchantAdminAccessToken, {
+ order: orderJsonDiscount,
+ }),
+ );
+
+ let orderStatus = succeedOrThrow(
+ await merchantApi.getOrderDetails(
+ merchantAdminAccessToken,
+ orderResp.order_id,
+ ),
+ );
+
+ t.assertTrue(orderStatus.order_status === "unpaid");
+
+ const talerPayUri = orderStatus.taler_pay_uri;
+
+ const preparePayResult = await walletClient.call(
+ WalletApiOperation.PreparePayForUriV2,
+ {
+ talerPayUri,
+ },
+ );
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: preparePayResult.transactionId,
+ txState: {
+ major: TransactionMajorState.Dialog,
+ minor: "*",
+ },
+ });
+
+ const tokBefore = await walletClient.call(
+ WalletApiOperation.ListDiscounts,
+ {},
+ );
+
+ console.log(`tokens before:`, j2s(tokBefore));
+
+ await walletClient.call(WalletApiOperation.ConfirmPay, {
+ transactionId: preparePayResult.transactionId,
+ choiceIndex: 2,
+ });
+
+ await walletClient.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: preparePayResult.transactionId,
+ txState: {
+ major: TransactionMajorState.Done,
+ },
+ });
+
+ const tokAfter = await walletClient.call(
+ WalletApiOperation.ListDiscounts,
+ {},
+ );
+
+ console.log(`tokens after:`, j2s(tokAfter));
+
+ t.assertDeepEqual(tokAfter.discounts[0].tokensAvailable, 5);
+ }
}
runWalletTokensTest.suites = ["merchant", "wallet"];
diff --git a/packages/taler-util/src/types-taler-wallet.ts b/packages/taler-util/src/types-taler-wallet.ts
@@ -1294,6 +1294,7 @@ export interface SlateCreationRequest {
secretSeed: string;
choiceIndex: number;
outputIndex: number;
+ repeatIndex: number;
tokenIssuePub: TokenIssuePublicKey;
genTokenUseSig: boolean;
contractTerms: MerchantContractTermsV1;
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -1031,10 +1031,11 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
// token generation
const info = stringToBytes("taler-payment-token-derivation");
- const saltArrBuf = new ArrayBuffer(4);
+ const saltArrBuf = new ArrayBuffer(8);
const salt = new Uint8Array(saltArrBuf);
const saltDataView = new DataView(saltArrBuf);
saltDataView.setUint32(0, req.outputIndex);
+ saltDataView.setUint32(4, req.repeatIndex);
const secretSeedDec = decodeCrock(req.secretSeed);
const out = kdf(64, secretSeedDec, salt, info);
const tokenPriv = out.slice(0, 32);
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
@@ -172,7 +172,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
* backwards-compatible way or object stores and indices
* are added.
*/
-export const WALLET_DB_MINOR_VERSION = 28;
+export const WALLET_DB_MINOR_VERSION = 29;
declare const symDbProtocolTimestamp: unique symbol;
@@ -1090,6 +1090,15 @@ export interface TokenRecord extends TokenFamilyInfo {
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;
@@ -3124,6 +3133,13 @@ export const WalletIndexedDbStoresV1 = {
versionAdded: 17,
},
),
+ byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex: describeIndex(
+ "byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex",
+ ["purchaseId", "choiceIndex", "outputIndex", "repeatIndex"],
+ {
+ versionAdded: 29,
+ },
+ ),
},
),
reserves: describeStore(
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -1254,6 +1254,7 @@ async function generateSlate(
contractTermsRaw: any,
choiceIndex: number,
outputIndex: number,
+ repeatIndex: number,
): Promise<void> {
checkDbInvariant(
purchase.secretSeed !== undefined,
@@ -1261,12 +1262,12 @@ async function generateSlate(
);
logger.trace(
- `generating slate (${choiceIndex}, ${outputIndex}) for purchase ${purchase.proposalId}`,
+ `generating slate (${choiceIndex}, ${outputIndex}, ${repeatIndex}) for purchase ${purchase.proposalId}`,
);
let slate = await wex.runLegacyWalletDbTx(async (tx) => {
- return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get(
- [purchase.proposalId, choiceIndex, outputIndex],
+ return await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get(
+ [purchase.proposalId, choiceIndex, outputIndex, repeatIndex],
);
});
@@ -1290,9 +1291,10 @@ async function generateSlate(
genTokenUseSig: true,
contractTerms: contractData,
contractTermsHash: ContractTermsUtil.hashContractTerms(contractTermsRaw),
+ repeatIndex,
});
- let newSlate: SlateRecord = {
+ const newSlate: SlateRecord = {
purchaseId: purchase.proposalId,
choiceIndex: choiceIndex,
outputIndex: outputIndex,
@@ -1312,17 +1314,16 @@ async function generateSlate(
tokenEv: r.tokenEv,
tokenEvHash: r.tokenEvHash,
blindingKey: r.blindingKey,
+ repeatIndex,
};
newSlate.tokenFamilyHash = TokenRecord.hashInfo(newSlate);
await wex.runLegacyWalletDbTx(async (tx) => {
const s =
- await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndex.get([
- purchase.proposalId,
- choiceIndex,
- outputIndex,
- ]);
+ await tx.slates.indexes.byPurchaseIdAndChoiceIndexAndOutputIndexAndRepeatIndex.get(
+ [purchase.proposalId, choiceIndex, outputIndex, repeatIndex],
+ );
if (s) {
return;
}
@@ -2973,14 +2974,19 @@ export async function confirmPay(
const tok = choice.outputs[j];
switch (tok.type) {
case MerchantContractOutputType.Token: {
- await generateSlate(
- wex,
- proposal,
- contractTerms,
- d.contractTermsRaw,
- choiceIndex,
- j,
- );
+ const tokCount = tok.count ?? 1;
+ for (let repeatIndex = 0; repeatIndex < tokCount; repeatIndex++) {
+ await generateSlate(
+ wex,
+ proposal,
+ contractTerms,
+ d.contractTermsRaw,
+ choiceIndex,
+ j,
+ repeatIndex,
+ );
+ }
+
break;
}
case MerchantContractOutputType.TaxReceipt:
@@ -3253,12 +3259,20 @@ async function processPurchasePay(
slates = [];
wallet_data = { choice_index: index, tokens_evs: [] };
await wex.runLegacyWalletDbTx(async (tx) => {
- (
+ const allSlateRes = await tx.slates.getAll();
+ logger.info(`all slates: ${j2s(allSlateRes)}`);
+ const slateRes =
await tx.slates.indexes.byPurchaseIdAndChoiceIndex.getAll([
purchase.proposalId,
index,
- ])
- ).forEach((s) => {
+ ]);
+ logger.info(
+ `got ${slateRes.length} slates from DB, req=${[
+ purchase.proposalId,
+ index,
+ ]}`,
+ );
+ slateRes.forEach((s) => {
slates?.push(s);
wallet_data?.tokens_evs.push(s.tokenEv);
});