commit 92ca0147caab915327f9a678991530616cc3395c
parent 65ba257cbbb81b6b4119fe7da814d7f3c3382fb8
Author: Florian Dold <dold@taler.net>
Date: Sat, 5 Sep 2026 16:37:07 +0200
wallet-core: create free pull purses with an unfunded reserve
Send an explicit zero purse fee and sign the matching creation mode when
the exchange advertises free purse creation. Quota requires an existing
reserve and remains the choice for exchanges charging a purse fee.
Choose the mode from the creation-time fee schedule to keep retries
consistent. Accept both authenticated creation modes during recovery.
Issue: https://bugs.taler.net/n/7903
Diffstat:
2 files changed, 49 insertions(+), 13 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.test.ts
@@ -20,6 +20,7 @@ import {
TransactionAction,
TransactionMajorState,
TransactionMinorState,
+ WalletAccountMergeFlags,
} from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
@@ -134,11 +135,18 @@ test("delete conflict continues a pull credit whose purse was merged", () => {
);
});
-test("pull-credit purse creation uses the reserve quota", () => {
+test("free pull purses use an explicit zero fee with a new reserve", () => {
+ assert.deepStrictEqual(getPullCreditPurseCreationTerms("TESTKUDOS:0"), {
+ flags: WalletAccountMergeFlags.CreateWithPurseFee,
+ purseFee: "TESTKUDOS:0",
+ });
+});
+
+test("pull-credit creation retains quota when the exchange charges a fee", () => {
assert.deepStrictEqual(
- getPullCreditPurseCreationTerms("TESTKUDOS:12" as AmountString),
+ getPullCreditPurseCreationTerms("TESTKUDOS:0.1" as AmountString),
{
- flags: 2,
+ flags: WalletAccountMergeFlags.CreateFromPurseQuota,
purseFee: "TESTKUDOS:0",
},
);
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -146,18 +146,22 @@ const defaultPeerPullExpiration = Duration.toTalerProtocolDuration(
);
/**
- * Pull-credit purses are created against the merge reserve's free purse
- * quota. That reserve is deliberately not funded before the payer merges
- * the purse, so promising to pay a purse fee from it is both misleading and
- * generally impossible.
+ * An explicit zero purse fee allows creation against a new, unfunded merge
+ * reserve. Omitting the fee requests quota, which requires an existing reserve.
+ * Use quota only when the exchange charges a purse fee; the wallet does not
+ * authorize spending reserve funds to create an invoice.
*/
-export function getPullCreditPurseCreationTerms(amount: AmountString): {
+export function getPullCreditPurseCreationTerms(
+ advertisedPurseFee: AmountString,
+): {
flags: WalletAccountMergeFlags;
purseFee: AmountString;
} {
return {
- flags: WalletAccountMergeFlags.CreateFromPurseQuota,
- purseFee: Amounts.stringify(Amounts.zeroOfAmount(amount)),
+ flags: Amounts.isZero(advertisedPurseFee)
+ ? WalletAccountMergeFlags.CreateWithPurseFee
+ : WalletAccountMergeFlags.CreateFromPurseQuota,
+ purseFee: Amounts.stringify(Amounts.zeroOfAmount(advertisedPurseFee)),
};
}
@@ -651,7 +655,11 @@ async function recoverPeerPullCreditFromReserve(
"contract terms for peer pull credit are missing",
);
const contractTerms = contractRecord.contractTermsRaw as PeerContractTerms;
- const purseCreationTerms = getPullCreditPurseCreationTerms(pullIni.amount);
+ // Both modes have been used by wallets. Authenticate the original reserve
+ // signature instead of inferring its mode from today's exchange fee.
+ const purseCreationTerms = getPullCreditPurseCreationTerms(
+ Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)),
+ );
const historyResult = await getMergedPurseAmountFromReserveHistory(wex, {
exchangeBaseUrl: pullIni.exchangeBaseUrl,
reserve,
@@ -668,7 +676,7 @@ async function recoverPeerPullCreditFromReserve(
alternativePurseTerms: [
{
purseFee: Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount)),
- flags: WalletAccountMergeFlags.CreateWithPurseFee,
+ flags: WalletAccountMergeFlags.CreateFromPurseQuota,
},
],
});
@@ -1102,7 +1110,24 @@ async function processPeerPullCreditCreatePurse(
return TaskRunResult.progress();
}
- const purseCreationTerms = getPullCreditPurseCreationTerms(pullIni.amount);
+ const exchangeDetails = await wex.runWalletDbTx((tx) =>
+ tx.getExchangeDetails(pullIni.exchangeBaseUrl),
+ );
+ // Keep the chosen mode stable on retries, even across a fee schedule change.
+ const creationTime = AbsoluteTime.fromPreciseTimestamp(
+ timestampPreciseFromDb(pullIni.mergeTimestamp),
+ );
+ const globalFee = exchangeDetails?.globalFees.find((fee) =>
+ AbsoluteTime.isBetween(
+ creationTime,
+ AbsoluteTime.fromProtocolTimestamp(fee.startDate),
+ AbsoluteTime.fromProtocolTimestamp(fee.endDate),
+ ),
+ );
+ checkDbInvariant(!!globalFee, "missing global fees for pull purse creation");
+ const purseCreationTerms = getPullCreditPurseCreationTerms(
+ globalFee.purseFee,
+ );
const mergeReserve = await wex.runWalletDbTx(async (tx) =>
tx.getReserve(pullIni.mergeReserveRowId),
@@ -1151,6 +1176,9 @@ async function processPeerPullCreditCreatePurse(
purse_pub: pullIni.pursePub,
purse_sig: sigRes.purseSig,
purse_value: pullIni.amount,
+ ...(purseCreationTerms.flags === WalletAccountMergeFlags.CreateWithPurseFee
+ ? { purse_fee: purseCreationTerms.purseFee }
+ : {}),
reserve_sig: sigRes.accountSig,
econtract: econtractResp.econtract,
};