commit 82ae3ed98955f1ec5a0092ff95c3fa1dfcb75406
parent 25fe7beae8fd18b008f4a014a7655be37fd0ccb9
Author: Florian Dold <dold@taler.net>
Date: Thu, 20 Aug 2026 19:06:47 +0200
wallet-core: map payment signatures by output index
Diffstat:
2 files changed, 91 insertions(+), 8 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -128,18 +128,24 @@ test("payment output signatures are mandatory and partitioned exactly", () => {
const donauSig = outputSig("donau");
assert.deepStrictEqual(
- splitPaymentOutputTokenSignatures([slateSig, donauSig], 1, 1),
+ splitPaymentOutputTokenSignatures([slateSig, donauSig], [0], 1, 1),
{
slateTokenSigs: [slateSig],
donauTokenSigs: [donauSig],
},
);
assert.throws(
- () => splitPaymentOutputTokenSignatures(undefined, 1, 0),
+ () => splitPaymentOutputTokenSignatures(undefined, [0], undefined, 0),
/returned 0 token signatures, expected 1/,
);
assert.throws(
- () => splitPaymentOutputTokenSignatures([slateSig, donauSig], 1, 0),
+ () =>
+ splitPaymentOutputTokenSignatures(
+ [slateSig, donauSig],
+ [0],
+ undefined,
+ 0,
+ ),
/returned 2 token signatures, expected 1/,
);
});
@@ -147,7 +153,13 @@ test("payment output signatures are mandatory and partitioned exactly", () => {
test("cached output signatures recover an older partial finalization", () => {
const cached = outputSig("cached");
assert.deepStrictEqual(
- splitPaymentOutputTokenSignatures(undefined, 1, 0, [cached]),
+ splitPaymentOutputTokenSignatures(
+ undefined,
+ [0],
+ undefined,
+ 0,
+ [cached],
+ ),
{
slateTokenSigs: [cached],
donauTokenSigs: [],
@@ -155,6 +167,26 @@ test("cached output signatures recover an older partial finalization", () => {
);
});
+test("payment output signatures follow interleaved contract outputs", () => {
+ const firstSlate = outputSig("slate-0");
+ const firstDonau = outputSig("donau-0");
+ const secondDonau = outputSig("donau-1");
+ const secondSlate = outputSig("slate-2");
+
+ assert.deepStrictEqual(
+ splitPaymentOutputTokenSignatures(
+ [firstSlate, firstDonau, secondDonau, secondSlate],
+ [0, 2],
+ 1,
+ 2,
+ ),
+ {
+ slateTokenSigs: [firstSlate, secondSlate],
+ donauTokenSigs: [firstDonau, secondDonau],
+ },
+ );
+});
+
function refundGroup(
id: string,
raw: AmountString,
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -1809,7 +1809,8 @@ async function storeFirstPaySuccess(
export function splitPaymentOutputTokenSignatures(
responseTokenSigs: SignedTokenEnvelope[] | undefined,
- slateCount: number,
+ slateOutputIndexes: number[],
+ donauOutputIndex: number | undefined,
donauCount: number,
cachedSlateSigs?: SignedTokenEnvelope[],
): {
@@ -1817,6 +1818,7 @@ export function splitPaymentOutputTokenSignatures(
donauTokenSigs: SignedTokenEnvelope[];
} {
const tokenSigs = responseTokenSigs ?? [];
+ const slateCount = slateOutputIndexes.length;
const expectedCount = slateCount + donauCount;
if (
@@ -1840,9 +1842,45 @@ export function splitPaymentOutputTokenSignatures(
`merchant returned ${tokenSigs.length} token signatures, expected ${expectedCount}`,
);
}
+
+ if (donauCount > 0 && donauOutputIndex === undefined) {
+ throw Error("donation signatures require a contract output index");
+ }
+
+ const responsePositions: Array<
+ | { outputIndex: number; subIndex: number; kind: "slate"; index: number }
+ | { outputIndex: number; subIndex: number; kind: "donau"; index: number }
+ > = slateOutputIndexes.map((outputIndex, index) => ({
+ outputIndex,
+ subIndex: index,
+ kind: "slate",
+ index,
+ }));
+ for (let index = 0; index < donauCount; index++) {
+ responsePositions.push({
+ outputIndex: donauOutputIndex!,
+ subIndex: index,
+ kind: "donau",
+ index,
+ });
+ }
+ responsePositions.sort(
+ (a, b) => a.outputIndex - b.outputIndex || a.subIndex - b.subIndex,
+ );
+
+ const slateTokenSigs: SignedTokenEnvelope[] = new Array(slateCount);
+ const donauTokenSigs: SignedTokenEnvelope[] = new Array(donauCount);
+ for (let responseIndex = 0; responseIndex < tokenSigs.length; responseIndex++) {
+ const position = responsePositions[responseIndex];
+ if (position.kind === "slate") {
+ slateTokenSigs[position.index] = tokenSigs[responseIndex];
+ } else {
+ donauTokenSigs[position.index] = tokenSigs[responseIndex];
+ }
+ }
return {
- slateTokenSigs: tokenSigs.slice(0, slateCount),
- donauTokenSigs: tokenSigs.slice(slateCount),
+ slateTokenSigs,
+ donauTokenSigs,
};
}
@@ -3271,6 +3309,11 @@ async function processPurchasePay(
index,
]}`,
);
+ slateRes.sort(
+ (a, b) =>
+ (a.outputIndex ?? 0) - (b.outputIndex ?? 0) ||
+ (a.repeatIndex ?? 0) - (b.repeatIndex ?? 0),
+ );
slateRes.forEach((s) => {
slates?.push(s);
wallet_data?.tokens_evs.push(s.tokenEv);
@@ -3284,6 +3327,7 @@ async function processPurchasePay(
// FIXME: Merge with transaction above
const res = await wex.runWalletDbTx(async (tx) => {
const recs = await tx.getDonationPlanchetsByProposal(proposalId);
+ recs.sort((a, b) => a.udiIndex - b.udiIndex);
for (const rec of recs) {
budikeypairs.push({
blinded_udi: rec.blindedUdi,
@@ -3455,7 +3499,14 @@ async function processPurchasePay(
const { slateTokenSigs, donauTokenSigs } =
splitPaymentOutputTokenSignatures(
merchantResp.token_sigs,
- slateList.length,
+ slateList.map((slate) => {
+ checkDbInvariant(
+ slate.outputIndex !== undefined,
+ `slate ${slate.tokenUsePub} has no output index`,
+ );
+ return slate.outputIndex;
+ }),
+ purchase.donauOutputIndex,
donauPlanchetList.length,
payInfo.slateTokenSigs,
);