commit c185141f244125ae556cdd22fa0d71e49c43da05
parent 2e23b6a46db646baa332065ce25344bfec1e1eab
Author: Florian Dold <dold@taler.net>
Date: Sat, 22 Aug 2026 21:34:33 +0200
wallet-core: fine-grained handling for P2P payment expirations
Diffstat:
11 files changed, 983 insertions(+), 108 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-peer-pull.ts b/packages/taler-harness/src/integrationtests/test-peer-pull.ts
@@ -19,6 +19,7 @@ import {
AmountString,
Duration,
TalerErrorCode,
+ TalerProtocolTimestamp,
TransactionMajorState,
TransactionMinorState,
TransactionPeerPullCredit,
@@ -144,6 +145,26 @@ export async function runPeerPullTest(t: GlobalTestState) {
t.logStep("P2P pull errors");
{
+ for (const invalidExpiration of [
+ TalerProtocolTimestamp.fromSeconds(1),
+ TalerProtocolTimestamp.never(),
+ ]) {
+ const invalidExpirationError = await t.assertThrowsTalerErrorAsync(() =>
+ wallet1.call(WalletApiOperation.InitiatePeerPullCredit, {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "invalid expiration",
+ amount: "TESTKUDOS:1",
+ purse_expiration: invalidExpiration,
+ },
+ }),
+ );
+ t.assertDeepEqual(
+ invalidExpirationError.errorDetail.code,
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ );
+ }
+
const tx = await initPeerPullCredit("confirm", "TESTKUDOS:1000");
const insufficient_balance = await t.assertThrowsTalerErrorAsync(async () =>
wallet1.call(WalletApiOperation.PreparePeerPullDebit, {
@@ -480,6 +501,43 @@ export async function runPeerPullTest(t: GlobalTestState) {
),
);
+ await wallet1.call(WalletApiOperation.HintNetworkAvailability, {
+ isNetworkAvailable: false,
+ });
+ const expiresBeforeCreate = await wallet1.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "expire before purse creation",
+ amount: "TESTKUDOS:2",
+ purse_expiration,
+ },
+ },
+ );
+ const suspendedBeforeCreate = await wallet1.call(
+ WalletApiOperation.InitiatePeerPullCredit,
+ {
+ exchangeBaseUrl: exchange.baseUrl,
+ partialContractTerms: {
+ summary: "suspended while purse expires before creation",
+ amount: "TESTKUDOS:2",
+ purse_expiration,
+ },
+ },
+ );
+ await wallet1.call(WalletApiOperation.SuspendTransaction, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ });
+ await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ },
+ timeout: { seconds: 10 },
+ });
+
const timetravelOffsetMs = Duration.toMilliseconds(
Duration.fromSpec({ days: 5 }),
);
@@ -497,6 +555,29 @@ export async function runPeerPullTest(t: GlobalTestState) {
),
);
+ await Promise.all([
+ wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: expiresBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Finalizing,
+ minor: TransactionMinorState.DeletePurse,
+ },
+ timeout: { seconds: 10 },
+ }),
+ wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ },
+ timeout: { seconds: 10 },
+ }),
+ ]);
+
+ await wallet1.call(WalletApiOperation.HintNetworkAvailability, {
+ isNetworkAvailable: true,
+ });
+
await wallet2.call(WalletApiOperation.ConfirmPeerPullDebit, {
transactionId: prepare2.transactionId,
});
@@ -523,8 +604,26 @@ export async function runPeerPullTest(t: GlobalTestState) {
},
timeout: { seconds: 10 },
}),
+ wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: expiresBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Expired,
+ },
+ timeout: { seconds: 10 },
+ }),
]);
+ await wallet1.call(WalletApiOperation.ResumeTransaction, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ });
+ await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Expired,
+ },
+ timeout: { seconds: 10 },
+ });
+
const expired_purse = await t.assertThrowsTalerErrorAsync(() =>
wallet1.call(WalletApiOperation.PreparePeerPullDebit, {
talerUri: tx.talerUri!,
diff --git a/packages/taler-harness/src/integrationtests/test-peer-push.ts b/packages/taler-harness/src/integrationtests/test-peer-push.ts
@@ -19,6 +19,7 @@ import {
AmountString,
Duration,
TalerErrorCode,
+ TalerProtocolTimestamp,
TransactionMajorState,
TransactionMinorState,
TransactionPeerPushDebit,
@@ -138,6 +139,25 @@ export async function runPeerPushTest(t: GlobalTestState) {
t.logStep("P2P push errors");
{
+ for (const invalidExpiration of [
+ TalerProtocolTimestamp.fromSeconds(1),
+ TalerProtocolTimestamp.never(),
+ ]) {
+ const invalidExpirationError = await t.assertThrowsTalerErrorAsync(() =>
+ wallet1.call(WalletApiOperation.InitiatePeerPushDebit, {
+ partialContractTerms: {
+ summary: "invalid expiration",
+ amount: "TESTKUDOS:1",
+ purse_expiration: invalidExpiration,
+ },
+ }),
+ );
+ t.assertDeepEqual(
+ invalidExpirationError.errorDetail.code,
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ );
+ }
+
const ex1 = await t.assertThrowsTalerErrorAsync(
async () =>
await wallet1.call(WalletApiOperation.InitiatePeerPushDebit, {
@@ -523,6 +543,41 @@ export async function runPeerPushTest(t: GlobalTestState) {
),
);
+ await wallet1.call(WalletApiOperation.HintNetworkAvailability, {
+ isNetworkAvailable: false,
+ });
+ const expiresBeforeCreate = await wallet1.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "expire before purse creation",
+ amount: "TESTKUDOS:5",
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+ const suspendedBeforeCreate = await wallet1.call(
+ WalletApiOperation.InitiatePeerPushDebit,
+ {
+ partialContractTerms: {
+ summary: "suspended while purse expires before creation",
+ amount: "TESTKUDOS:5",
+ purse_expiration: purseExpiration,
+ },
+ },
+ );
+ await wallet1.call(WalletApiOperation.SuspendTransaction, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ });
+ await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ },
+ timeout: { seconds: 10 },
+ });
+
const timetravelOffsetMs = Duration.toMilliseconds(
Duration.fromSpec({ days: 5 }),
);
@@ -539,6 +594,29 @@ export async function runPeerPushTest(t: GlobalTestState) {
),
);
+ await Promise.all([
+ wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: expiresBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Aborting,
+ minor: TransactionMinorState.DeletePurse,
+ },
+ timeout: { seconds: 10 },
+ }),
+ wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.CreatePurse,
+ },
+ timeout: { seconds: 10 },
+ }),
+ ]);
+
+ await wallet1.call(WalletApiOperation.HintNetworkAvailability, {
+ isNetworkAvailable: true,
+ });
+
await wallet2.client.call(WalletApiOperation.ConfirmPeerPushCredit, {
transactionId: prepare2.transactionId,
});
@@ -565,7 +643,25 @@ export async function runPeerPushTest(t: GlobalTestState) {
major: TransactionMajorState.Expired,
},
}),
+ wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: expiresBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Expired,
+ },
+ timeout: { seconds: 10 },
+ }),
]);
+
+ await wallet1.call(WalletApiOperation.ResumeTransaction, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ });
+ await wallet1.call(WalletApiOperation.TestingWaitTransactionState, {
+ transactionId: suspendedBeforeCreate.transactionId,
+ txState: {
+ major: TransactionMajorState.Expired,
+ },
+ timeout: { seconds: 10 },
+ });
}
// Test deleting p2p transaction.
diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts
@@ -431,6 +431,8 @@ export class TalerExchangeHttpClient {
| OperationFail<HttpStatusCode.NotFound>
| OperationAlternative<HttpStatusCode.Conflict, PurseConflict>
| OperationFail<HttpStatusCode.TooEarly>
+ | OperationFail<TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_BEFORE_NOW>
+ | OperationFail<TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_IS_NEVER>
> {
const resp = await this.fetch(`purses/${pursePub}/create`, {
method: "POST",
@@ -449,6 +451,16 @@ export class TalerExchangeHttpClient {
case HttpStatusCode.NotFound:
case HttpStatusCode.TooEarly:
return opKnownHttpFailure(resp.status, resp);
+ case HttpStatusCode.BadRequest: {
+ const details = await readTalerErrorResponse(resp);
+ switch (details.code) {
+ case TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_BEFORE_NOW:
+ case TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_IS_NEVER:
+ return opKnownTalerFailure(resp, details.code, details);
+ default:
+ return opUnknownHttpFailure(resp, details);
+ }
+ }
default:
return opUnknownHttpFailure(resp);
}
diff --git a/packages/taler-util/src/http-client/status-handling.test.ts b/packages/taler-util/src/http-client/status-handling.test.ts
@@ -27,6 +27,7 @@ import { TalerErrorCode } from "../taler-error-codes.js";
import { MonitorTimeframeParam } from "../types-taler-corebank.js";
import { TalerBankConversionHttpClient } from "./bank-conversion.js";
import { TalerCoreBankHttpClient } from "./bank-core.js";
+import { TalerExchangeHttpClient } from "./exchange-client.js";
/**
* Answers every request with a canned status and body, recording the URL.
@@ -96,3 +97,17 @@ test("the cash-out rate reports the Taler error code, like the cash-in rate", as
assert.strictEqual(res.type, "fail");
assert.strictEqual(res.case, TalerErrorCode.GENERIC_PARAMETER_MALFORMED);
});
+
+for (const code of [
+ TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_BEFORE_NOW,
+ TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_IS_NEVER,
+]) {
+ test(`deposit purse creation exposes expiration error ${code}`, async () => {
+ const client = new TalerExchangeHttpClient("https://exchange.example/", {
+ httpClient: new StubHttpLib(400, { code, hint: "invalid expiration" }),
+ });
+ const res = await client.createPurseFromDeposit("PURSE", {});
+ assert.strictEqual(res.type, "fail");
+ assert.strictEqual(res.case, code);
+ });
+}
diff --git a/packages/taler-wallet-core/src/db/records.ts b/packages/taler-wallet-core/src/db/records.ts
@@ -2199,6 +2199,10 @@ export enum PeerPullPaymentCreditStatus {
AbortingDeletePurse = 0x0103_0000,
SuspendedAbortingDeletePurse = 0x0113_0000,
+ /** Deleting an expired purse before completing the transaction. */
+ ExpiredDeletePurse = 0x0103_0003,
+ SuspendedExpiredDeletePurse = 0x0113_0003,
+
/** Deleting the purse after an unraisable merge hard limit. */
FinalizingKycHardLimit = 0x0200_0000,
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
@@ -152,3 +152,28 @@ test("pull-credit hard-KYC cleanup has explicit finalizing and failed states", (
[TransactionAction.Retry],
);
});
+
+test("pull-credit expiration cleanup is resumable without becoming an abort", () => {
+ assert.deepStrictEqual(
+ computePeerPullCreditTransactionState(
+ makeRecord(PeerPullPaymentCreditStatus.ExpiredDeletePurse),
+ ),
+ {
+ major: TransactionMajorState.Finalizing,
+ minor: TransactionMinorState.DeletePurse,
+ working: true,
+ },
+ );
+ assert.deepStrictEqual(
+ computePeerPullCreditTransactionActions(
+ makeRecord(PeerPullPaymentCreditStatus.ExpiredDeletePurse),
+ ),
+ [TransactionAction.Retry, TransactionAction.Suspend],
+ );
+ assert.deepStrictEqual(
+ computePeerPullCreditTransactionActions(
+ makeRecord(PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse),
+ ),
+ [TransactionAction.Resume],
+ );
+});
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts
@@ -15,6 +15,7 @@
*/
import {
+ AbsoluteTime,
Amounts,
CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse,
@@ -36,6 +37,7 @@ import {
TalerErrorCode,
TalerErrorDetail,
TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
TalerUriAction,
TalerUris,
Transaction,
@@ -99,6 +101,7 @@ import {
} from "./kyc.js";
import {
getMergeReserveInfo,
+ isPurseGoneByExpiration,
isPurseDeposited,
isPurseMerged,
} from "./pay-peer-common.js";
@@ -121,6 +124,21 @@ const defaultPeerPullExpiration = Duration.toTalerProtocolDuration(
Duration.fromSpec({ days: 1 }),
);
+function requireFutureFinitePurseExpiration(
+ purseExpiration: TalerProtocolTimestamp,
+): void {
+ if (
+ TalerProtocolTimestamp.isNever(purseExpiration) ||
+ isPurseGoneByExpiration(purseExpiration)
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "partialContractTerms.purse_expiration" },
+ "peer pull payment expiration must be in the future and finite",
+ );
+ }
+}
+
export class PeerPullCreditTransactionContext implements TransactionContext {
readonly transactionId: TransactionIdStr;
readonly taskId: TaskIdStr;
@@ -349,6 +367,9 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
rec.status = PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
break;
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
+ rec.status = PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse;
+ break;
case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
rec.status = PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired;
break;
@@ -362,6 +383,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
case PeerPullPaymentCreditStatus.Done:
case PeerPullPaymentCreditStatus.Aborted:
case PeerPullPaymentCreditStatus.Failed:
@@ -418,6 +440,8 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
case PeerPullPaymentCreditStatus.PendingBalanceKycInit:
case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
return;
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
@@ -446,6 +470,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
case PeerPullPaymentCreditStatus.PendingBalanceKycInit:
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
case PeerPullPaymentCreditStatus.Done:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.FailedKycHardLimit:
@@ -471,6 +496,9 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
rec.status = PeerPullPaymentCreditStatus.AbortingDeletePurse;
break;
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
+ rec.status = PeerPullPaymentCreditStatus.ExpiredDeletePurse;
+ break;
case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
rec.status = PeerPullPaymentCreditStatus.PendingBalanceKycRequired;
break;
@@ -518,6 +546,8 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
case PeerPullPaymentCreditStatus.FailedKycHardLimit:
case PeerPullPaymentCreditStatus.Expired:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
case PeerPullPaymentCreditStatus.FinalizingKycHardLimit:
return;
default:
@@ -529,6 +559,37 @@ export class PeerPullCreditTransactionContext implements TransactionContext {
}
}
+function isActiveUndecidedPullCreditStatus(
+ status: PeerPullPaymentCreditStatus,
+): boolean {
+ switch (status) {
+ case PeerPullPaymentCreditStatus.PendingCreatePurse:
+ case PeerPullPaymentCreditStatus.PendingReady:
+ case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
+ case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
+ case PeerPullPaymentCreditStatus.PendingBalanceKycInit:
+ return true;
+ default:
+ return false;
+ }
+}
+
+async function beginPeerPullCreditExpirationCleanup(
+ wex: WalletExecutionContext,
+ pullIni: WalletPeerPullCredit,
+ reason: string,
+): Promise<void> {
+ const ctx = new PeerPullCreditTransactionContext(wex, pullIni.pursePub);
+ await wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (!rec || !isActiveUndecidedPullCreditStatus(rec.status)) {
+ return;
+ }
+ rec.status = PeerPullPaymentCreditStatus.ExpiredDeletePurse;
+ await h.update(rec, reason);
+ });
+}
+
async function processPendingReady(
wex: WalletExecutionContext,
pullIni: WalletPeerPullCredit,
@@ -706,9 +767,13 @@ async function processPeerPullCreditAbortingDeletePurse(
const resp = await exchangeClient.deletePurse(pursePub, sigResp.sig);
const hardLimitRecovery =
peerPullIni.status === PeerPullPaymentCreditStatus.FinalizingKycHardLimit;
- let completionStatus = hardLimitRecovery
- ? PeerPullPaymentCreditStatus.FailedKycHardLimit
- : PeerPullPaymentCreditStatus.Aborted;
+ const expirationRecovery =
+ peerPullIni.status === PeerPullPaymentCreditStatus.ExpiredDeletePurse;
+ let completionStatus = expirationRecovery
+ ? PeerPullPaymentCreditStatus.Expired
+ : hardLimitRecovery
+ ? PeerPullPaymentCreditStatus.FailedKycHardLimit
+ : PeerPullPaymentCreditStatus.Aborted;
let completionReason: TalerErrorDetail | undefined;
switch (resp.case) {
case "ok":
@@ -732,9 +797,9 @@ async function processPeerPullCreditAbortingDeletePurse(
);
// If the payer won the race with our abort, continue through the
// normal reserve-withdrawal path instead of retrying DELETE.
- completionStatus = statusAfterPullCreditDeleteConflict(
- statusResp.body,
- );
+ if (isPurseMerged(statusResp.body)) {
+ completionStatus = PeerPullPaymentCreditStatus.PendingReady;
+ }
break;
case HttpStatusCode.Gone:
case HttpStatusCode.NotFound:
@@ -751,10 +816,7 @@ async function processPeerPullCreditAbortingDeletePurse(
await wex.runWalletDbTx(async (tx) => {
const [rec, h] = await ctx.getRecordHandle(tx);
- if (
- rec?.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse &&
- rec?.status !== PeerPullPaymentCreditStatus.FinalizingKycHardLimit
- ) {
+ if (rec?.status !== peerPullIni.status) {
return;
}
rec.status = completionStatus;
@@ -770,7 +832,9 @@ async function processPeerPullCreditAbortingDeletePurse(
? "abort-purse-merged"
: hardLimitRecovery
? "kyc-hard-limit-delete-purse"
- : "aborting-delete-purse",
+ : expirationRecovery
+ ? "expire-delete-purse"
+ : "aborting-delete-purse",
);
});
return completionStatus === PeerPullPaymentCreditStatus.PendingReady
@@ -858,6 +922,22 @@ async function processPeerPullCreditCreatePurse(
pullIni: WalletPeerPullCredit,
): Promise<TaskRunResult> {
const ctx = new PeerPullCreditTransactionContext(wex, pullIni.pursePub);
+ const contractTermsRecord = await wex.runWalletDbTx(async (tx) =>
+ tx.getContractTerms(pullIni.contractTermsHash),
+ );
+ if (!contractTermsRecord) {
+ throw Error("contract terms for peer pull payment not found in database");
+ }
+ const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
+ const purseExpiration = contractTerms.purse_expiration;
+ if (isPurseGoneByExpiration(purseExpiration)) {
+ await beginPeerPullCreditExpirationCleanup(
+ wex,
+ pullIni,
+ "create-purse-expired",
+ );
+ return TaskRunResult.progress();
+ }
const kycCheckRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
wex,
@@ -897,15 +977,6 @@ async function processPeerPullCreditCreatePurse(
throw Error("merge reserve for peer pull payment not found in database");
}
- const contractTermsRecord = await wex.runWalletDbTx(async (tx) =>
- tx.getContractTerms(pullIni.contractTermsHash),
- );
- if (!contractTermsRecord) {
- throw Error("contract terms for peer pull payment not found in database");
- }
-
- const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
-
const reservePayto = reservePaytoFromExchange(
pullIni.exchangeBaseUrl,
mergeReserve.reservePub,
@@ -922,7 +993,6 @@ async function processPeerPullCreditCreatePurse(
const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
- const purseExpiration = contractTerms.purse_expiration;
const sigRes = await wex.cryptoApi.signReservePurseCreate({
contractTermsHash: pullIni.contractTermsHash,
flags: WalletAccountMergeFlags.CreateWithPurseFee,
@@ -956,6 +1026,15 @@ async function processPeerPullCreditCreatePurse(
logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
+ if (isPurseGoneByExpiration(purseExpiration)) {
+ await beginPeerPullCreditExpirationCleanup(
+ wex,
+ pullIni,
+ "create-purse-expired-before-request",
+ );
+ return TaskRunResult.progress();
+ }
+
const resp = await exchangeClient.createPurseFromReserve(
mergeReserve.reservePub,
reservePurseReqBody,
@@ -986,11 +1065,12 @@ async function processPeerPullCreditCreatePurse(
case HttpStatusCode.PaymentRequired:
throw Error(`unexpected reserve merge response ${resp.case}`);
case TalerErrorCode.EXCHANGE_RESERVES_PURSE_EXPIRATION_BEFORE_NOW:
- await ctx.failTransaction(
- pullIni.status,
- resp.detail ? { code: resp.detail?.code } : undefined,
+ await beginPeerPullCreditExpirationCleanup(
+ wex,
+ pullIni,
+ "create-purse-expiration-rejected",
);
- return TaskRunResult.finished();
+ return TaskRunResult.progress();
default:
assertUnreachable(resp);
}
@@ -1010,16 +1090,54 @@ export async function processPeerPullCredit(
wex: WalletExecutionContext,
pursePub: string,
): Promise<TaskRunResult> {
- if (!wex.ws.networkAvailable) {
- return TaskRunResult.networkRequired();
- }
-
- const pullIni = await wex.runWalletDbTx(async (tx) =>
- tx.getPeerPullCredit(pursePub),
- );
+ const processInfo = await wex.runWalletDbTx(async (tx) => {
+ const pullIni = await tx.getPeerPullCredit(pursePub);
+ if (!pullIni) {
+ return undefined;
+ }
+ const contractTerms = await tx.getContractTerms(pullIni.contractTermsHash);
+ return { pullIni, contractTerms };
+ });
+ const pullIni = processInfo?.pullIni;
if (!pullIni) {
throw Error("peer pull payment initiation not found in database");
}
+ if (!processInfo.contractTerms) {
+ throw Error("peer pull payment contract terms not found in database");
+ }
+
+ const purseExpiration =
+ processInfo.contractTerms.contractTermsRaw.purse_expiration;
+ if (
+ isActiveUndecidedPullCreditStatus(pullIni.status) &&
+ isPurseGoneByExpiration(purseExpiration)
+ ) {
+ await beginPeerPullCreditExpirationCleanup(wex, pullIni, "purse-expired");
+ return TaskRunResult.progress();
+ }
+
+ switch (pullIni.status) {
+ case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
+ case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
+ case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
+ case PeerPullPaymentCreditStatus.SuspendedReady:
+ case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
+ case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
+ return TaskRunResult.finished();
+ default:
+ break;
+ }
+
+ if (!wex.ws.networkAvailable) {
+ if (isActiveUndecidedPullCreditStatus(pullIni.status)) {
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.fromProtocolTimestamp(purseExpiration),
+ );
+ }
+ return TaskRunResult.networkRequired();
+ }
const ctx = new PeerPullCreditTransactionContext(wex, pullIni.pursePub);
logger.trace(`processing ${ctx.taskId}, status=${pullIni.status}`);
@@ -1038,6 +1156,7 @@ export async function processPeerPullCredit(
case PeerPullPaymentCreditStatus.PendingCreatePurse:
return await processPeerPullCreditCreatePurse(wex, pullIni);
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
case PeerPullPaymentCreditStatus.FinalizingKycHardLimit:
return await processPeerPullCreditAbortingDeletePurse(wex, pullIni);
case PeerPullPaymentCreditStatus.PendingWithdrawing:
@@ -1049,13 +1168,6 @@ export async function processPeerPullCredit(
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.FailedKycHardLimit:
case PeerPullPaymentCreditStatus.Expired:
- case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
- case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
- case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
- case PeerPullPaymentCreditStatus.SuspendedReady:
- case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
- case PeerPullPaymentCreditStatus.SuspendedBalanceKycInit:
return TaskRunResult.finished();
default:
assertUnreachable(pullIni.status);
@@ -1283,6 +1395,7 @@ async function internalInitiatePeerPullPayment(
wex: WalletExecutionContext,
req: InitiatePeerPullCreditRequest,
): Promise<InitiatePeerPullCreditResponse> {
+ requireFutureFinitePurseExpiration(req.partialContractTerms.purse_expiration);
const currency = Amounts.currencyOf(req.partialContractTerms.amount);
let maybeExchangeBaseUrl: string | undefined;
if (req.exchangeBaseUrl) {
@@ -1487,6 +1600,12 @@ export function computePeerPullCreditTransactionState(
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.DeletePurse,
};
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
+ return {
+ major: TransactionMajorState.Finalizing,
+ minor: TransactionMinorState.DeletePurse,
+ working: true,
+ };
case PeerPullPaymentCreditStatus.Failed:
return {
major: TransactionMajorState.Failed,
@@ -1500,6 +1619,11 @@ export function computePeerPullCreditTransactionState(
major: TransactionMajorState.SuspendedAborting,
minor: TransactionMinorState.DeletePurse,
};
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.DeletePurse,
+ };
case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
return {
major: TransactionMajorState.Pending,
@@ -1570,12 +1694,16 @@ export function computePeerPullCreditTransactionActions(
TransactionAction.Suspend,
TransactionAction.Fail,
];
+ case PeerPullPaymentCreditStatus.ExpiredDeletePurse:
+ return [TransactionAction.Retry, TransactionAction.Suspend];
case PeerPullPaymentCreditStatus.Failed:
return [TransactionAction.Delete];
case PeerPullPaymentCreditStatus.Expired:
return [TransactionAction.Delete];
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPullPaymentCreditStatus.SuspendedExpiredDeletePurse:
+ return [TransactionAction.Resume];
case PeerPullPaymentCreditStatus.PendingBalanceKycRequired:
return [TransactionAction.Suspend, TransactionAction.Abort];
case PeerPullPaymentCreditStatus.SuspendedBalanceKycRequired:
diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts
@@ -22,6 +22,7 @@
import {
AcceptPeerPullPaymentResponse,
+ AbsoluteTime,
AmountJson,
AmountLike,
Amounts,
@@ -102,6 +103,7 @@ import {
getTotalPeerPaymentCostInTx,
isPurseDeposited,
isPurseGoneByExpiration,
+ isPurseMerged,
queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { createRefreshGroup } from "./refresh.js";
@@ -941,6 +943,48 @@ async function processPeerPullDebitPendingDeposit(
}
}
+ const contractTerms = await wex.runWalletDbTx(async (tx) =>
+ tx.getContractTerms(peerPullInc.contractTermsHash),
+ );
+ checkDbInvariant(
+ !!contractTerms,
+ `no contract terms for peer pull debit ${peerPullInc.peerPullDebitId}`,
+ );
+
+ if (
+ isPurseGoneByExpiration(contractTerms.contractTermsRaw.purse_expiration)
+ ) {
+ const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);
+ const statusResp = await exchangeClient.getPurseStatusAtMerge(
+ pursePub,
+ true,
+ );
+ switch (statusResp.case) {
+ case "ok":
+ await requireValidExchangePurseStatus(
+ wex,
+ exchangeBaseUrl,
+ statusResp.body,
+ );
+ if (isPurseMerged(statusResp.body)) {
+ await ctx.purseGoneTransaction(peerPullInc.status);
+ return TaskRunResult.finished();
+ }
+ // The local clock reached the deadline first. Reconcile again after
+ // the exchange has either expired or merged the purse, but do not
+ // select or submit any more coins.
+ return TaskRunResult.longpollReturnedPending();
+ case HttpStatusCode.Gone:
+ await ctx.purseGoneTransaction(peerPullInc.status);
+ return TaskRunResult.finished();
+ case HttpStatusCode.NotFound:
+ await ctx.failTransaction(peerPullInc.status, statusResp.detail);
+ return TaskRunResult.finished();
+ default:
+ assertUnreachable(statusResp);
+ }
+ }
+
// This can happen when there was a prospective coin selection, or after a
// conflict where the already-confirmed prefix was retained and every
// unsubmitted coin was sent to recovery.
@@ -1015,6 +1059,11 @@ async function processPeerPullDebitPendingDeposit(
if (rec.status !== PeerPullDebitRecordStatus.PendingDeposit) {
return TaskRunResult.backoff();
}
+ if (
+ isPurseGoneByExpiration(contractTerms.contractTermsRaw.purse_expiration)
+ ) {
+ return TaskRunResult.progress();
+ }
const acceptedCount = rec.coinSel
? getPeerPullDebitDepositedCoinCount(rec.coinSel)
: 0;
@@ -1061,14 +1110,6 @@ async function processPeerPullDebitPendingDeposit(
}
const exchangeClient = walletExchangeClient(peerPullInc.exchangeBaseUrl, wex);
- const contractTerms = await wex.runWalletDbTx(async (tx) =>
- tx.getContractTerms(peerPullInc.contractTermsHash),
- );
- checkDbInvariant(
- !!contractTerms,
- `no contract terms for peer pull debit ${peerPullInc.peerPullDebitId}`,
- );
-
// FIXME: We could skip batches that we've already submitted.
const coins = await queryCoinInfosForSelection(wex, coinSel);
@@ -1077,6 +1118,11 @@ async function processPeerPullDebitPendingDeposit(
const depositedCoinCount = getPeerPullDebitDepositedCoinCount(coinSel);
for (let i = depositedCoinCount; i < coins.length; i += maxBatchSize) {
+ if (
+ isPurseGoneByExpiration(contractTerms.contractTermsRaw.purse_expiration)
+ ) {
+ return TaskRunResult.progress();
+ }
const batchSize = Math.min(maxBatchSize, coins.length - i);
wex.oc.observe({
@@ -1104,7 +1150,7 @@ async function processPeerPullDebitPendingDeposit(
depositPayload,
);
switch (resp.case) {
- case "ok":
+ case "ok": {
await requireValidExchangePurseDepositConfirmation(wex, {
exchangeBaseUrl,
pursePub,
@@ -1185,6 +1231,7 @@ async function processPeerPullDebitPendingDeposit(
return TaskRunResult.finished();
}
continue;
+ }
case HttpStatusCode.Gone: {
await ctx.purseGoneTransaction(peerPullInc.status);
return TaskRunResult.finished();
@@ -1262,13 +1309,17 @@ export async function processPeerPullDebit(
wex: WalletExecutionContext,
peerPullDebitId: string,
): Promise<TaskRunResult> {
- if (!wex.ws.networkAvailable) {
- return TaskRunResult.networkRequired();
- }
-
- const peerPullInc = await wex.runWalletDbTx(async (tx) =>
- tx.getPeerPullDebit(peerPullDebitId),
- );
+ const processInfo = await wex.runWalletDbTx(async (tx) => {
+ const peerPullInc = await tx.getPeerPullDebit(peerPullDebitId);
+ if (!peerPullInc) {
+ return undefined;
+ }
+ const contractTerms = await tx.getContractTerms(
+ peerPullInc.contractTermsHash,
+ );
+ return { peerPullInc, contractTerms };
+ });
+ const peerPullInc = processInfo?.peerPullInc;
if (!peerPullInc) {
throw makeTransactionNotFoundError(
constructTransactionIdentifier({
@@ -1277,14 +1328,14 @@ export async function processPeerPullDebit(
}),
);
}
+ checkDbInvariant(
+ !!processInfo.contractTerms,
+ `no contract terms for peer pull debit ${peerPullDebitId}`,
+ );
+ const purseExpiration =
+ processInfo.contractTerms.contractTermsRaw.purse_expiration;
switch (peerPullInc.status) {
- case PeerPullDebitRecordStatus.DialogProposed:
- return processPeerPullDebitDialogProposed(wex, peerPullInc);
- case PeerPullDebitRecordStatus.PendingDeposit:
- return processPeerPullDebitPendingDeposit(wex, peerPullInc);
- case PeerPullDebitRecordStatus.AbortingRefresh:
- return processPeerPullDebitAbortingRefresh(wex, peerPullInc);
case PeerPullDebitRecordStatus.Done:
case PeerPullDebitRecordStatus.Aborted:
case PeerPullDebitRecordStatus.Failed:
@@ -1293,6 +1344,42 @@ export async function processPeerPullDebit(
case PeerPullDebitRecordStatus.SuspendedDeposit:
return TaskRunResult.finished();
default:
+ break;
+ }
+
+ if (
+ peerPullInc.status === PeerPullDebitRecordStatus.DialogProposed &&
+ isPurseGoneByExpiration(purseExpiration)
+ ) {
+ const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
+ await ctx.purseGoneTransaction(peerPullInc.status);
+ return TaskRunResult.finished();
+ }
+
+ if (!wex.ws.networkAvailable) {
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ if (!isPurseGoneByExpiration(purseExpiration)) {
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.fromProtocolTimestamp(purseExpiration),
+ );
+ }
+ break;
+ default:
+ break;
+ }
+ return TaskRunResult.networkRequired();
+ }
+
+ switch (peerPullInc.status) {
+ case PeerPullDebitRecordStatus.DialogProposed:
+ return processPeerPullDebitDialogProposed(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.PendingDeposit:
+ return processPeerPullDebitPendingDeposit(wex, peerPullInc);
+ case PeerPullDebitRecordStatus.AbortingRefresh:
+ return processPeerPullDebitAbortingRefresh(wex, peerPullInc);
+ default:
assertUnreachable(peerPullInc.status);
}
}
@@ -1328,6 +1415,19 @@ export async function confirmPeerPullDebit(
return { transactionId: ctx.transactionId };
}
+ const contractTerms = await wex.runWalletDbTx(async (tx) =>
+ tx.getContractTerms(peerPullInc.contractTermsHash),
+ );
+ checkDbInvariant(
+ !!contractTerms,
+ `no contract terms for peer pull debit ${peerPullInc.peerPullDebitId}`,
+ );
+ const purseExpiration = contractTerms.contractTermsRaw.purse_expiration;
+ if (isPurseGoneByExpiration(purseExpiration)) {
+ await ctx.purseGoneTransaction(peerPullInc.status);
+ return { transactionId: ctx.transactionId };
+ }
+
const exchangeBaseUrl = peerPullInc.exchangeBaseUrl;
const statusResp = await walletExchangeClient(
@@ -1397,6 +1497,11 @@ export async function confirmPeerPullDebit(
if (rec.status !== PeerPullDebitRecordStatus.DialogProposed) {
return;
}
+ if (isPurseGoneByExpiration(purseExpiration)) {
+ rec.status = PeerPullDebitRecordStatus.Expired;
+ await h.update(rec, "confirm-expired");
+ return;
+ }
if (coinSelRes.type == "success") {
await spendCoins(wex, tx, {
transactionId: ctx.transactionId,
@@ -1581,6 +1686,14 @@ async function internalPreparePeerPullDebit(
);
}
+ if (isPurseGoneByExpiration(contractTerms.purse_expiration)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PULL_DEBIT_PURSE_GONE,
+ { purseExpired: true },
+ "this invoice expired",
+ );
+ }
+
const resp = await runWithProgressRetries(wex, () =>
exchangeClient.getPurseStatusAtMerge(pursePub),
);
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -77,6 +77,7 @@ import {
timestampPreciseToDb,
WalletPeerPushCredit,
WalletOperationRetry,
+ WalletReserve,
WalletWithdrawalGroup,
WithdrawalRecordType,
} from "./db/records.js";
@@ -116,7 +117,6 @@ import {
} from "./transactions.js";
import { WalletExecutionContext, walletExchangeClient } from "./wallet.js";
import {
- PerformCreateWithdrawalGroupResult,
WithdrawTransactionContext,
getExchangeWithdrawalInfo,
internalPerformCreateWithdrawalGroup,
@@ -637,6 +637,13 @@ async function internalPreparePeerPushCredit(
});
const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+ if (isPurseGoneByExpiration(contractTerms.purse_expiration)) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_PEER_PUSH_CREDIT_PURSE_GONE,
+ { purseExpired: true },
+ "this payment expired",
+ );
+ }
const resp = await runWithProgressRetries(wex, () =>
exchangeClient.getPurseStatusAtDeposit(pursePub),
);
@@ -750,6 +757,9 @@ async function processPeerPushDebitMergeKyc(
peerInc: WalletPeerPushCredit,
contractTerms: PeerContractTerms,
): Promise<TaskRunResult> {
+ if (isPurseGoneByExpiration(contractTerms.purse_expiration)) {
+ return reconcileExpiredPeerPushCredit(wex, peerInc, contractTerms);
+ }
const ctx = new PeerPushCreditTransactionContext(
wex,
peerInc.peerPushCreditId,
@@ -903,6 +913,128 @@ async function transitionPeerPushCreditKycRequired(
});
}
+async function continuePeerPushCreditAfterMerge(
+ wex: WalletExecutionContext,
+ peerInc: WalletPeerPushCredit,
+ amount: ReturnType<typeof Amounts.parseOrThrow>,
+ mergeReserveInfo: WalletReserve,
+): Promise<TaskRunResult> {
+ const ctx = new PeerPushCreditTransactionContext(
+ wex,
+ peerInc.peerPushCreditId,
+ );
+ const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, {
+ amount,
+ wgInfo: {
+ withdrawalType: WithdrawalRecordType.PeerPushCredit,
+ },
+ forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
+ reserveKeyPair: {
+ priv: mergeReserveInfo.reservePriv,
+ pub: mergeReserveInfo.reservePub,
+ },
+ });
+
+ await wex.runWalletDbTx(async (tx) => {
+ const [current, h] = await ctx.getRecordHandle(tx);
+ if (!current) {
+ return;
+ }
+ switch (current.status) {
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingBalanceKycRequired:
+ case PeerPushCreditStatus.PendingBalanceKycInit: {
+ current.status = PeerPushCreditStatus.PendingWithdrawing;
+ const wgCreateRes = await internalPerformCreateWithdrawalGroup(
+ wex,
+ tx,
+ withdrawalGroupPrep,
+ );
+ current.withdrawalGroupId =
+ wgCreateRes.withdrawalGroup.withdrawalGroupId;
+ break;
+ }
+ default:
+ return;
+ }
+ await h.update(current, "merge-done");
+ });
+ return TaskRunResult.backoff();
+}
+
+function isActivePreMergePeerPushCreditStatus(
+ status: PeerPushCreditStatus,
+): boolean {
+ switch (status) {
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingBalanceKycRequired:
+ case PeerPushCreditStatus.PendingBalanceKycInit:
+ return true;
+ default:
+ return false;
+ }
+}
+
+async function reconcileExpiredPeerPushCredit(
+ wex: WalletExecutionContext,
+ peerInc: WalletPeerPushCredit,
+ contractTerms: PeerContractTerms,
+): Promise<TaskRunResult> {
+ const exchangeClient = walletExchangeClient(peerInc.exchangeBaseUrl, wex);
+ const statusResp = await exchangeClient.getPurseStatusAtMerge(
+ peerInc.pursePub,
+ true,
+ );
+ switch (statusResp.case) {
+ case "ok":
+ await requireValidExchangePurseStatus(
+ wex,
+ peerInc.exchangeBaseUrl,
+ statusResp.body,
+ );
+ if (!isPurseMerged(statusResp.body)) {
+ return TaskRunResult.longpollReturnedPending();
+ }
+ return continuePeerPushCreditAfterMerge(
+ wex,
+ peerInc,
+ Amounts.parseOrThrow(contractTerms.amount),
+ await getMergeReserveInfo(wex, {
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ }),
+ );
+ case HttpStatusCode.Gone: {
+ const ctx = new PeerPushCreditTransactionContext(
+ wex,
+ peerInc.peerPushCreditId,
+ );
+ await wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (!rec || !isActivePreMergePeerPushCreditStatus(rec.status)) {
+ return;
+ }
+ rec.status = PeerPushCreditStatus.Expired;
+ await h.update(rec, "expired-before-merge");
+ });
+ return TaskRunResult.finished();
+ }
+ case HttpStatusCode.NotFound: {
+ const ctx = new PeerPushCreditTransactionContext(
+ wex,
+ peerInc.peerPushCreditId,
+ );
+ await ctx.failTransaction(peerInc.status, statusResp.detail);
+ return TaskRunResult.finished();
+ }
+ default:
+ assertUnreachable(statusResp);
+ }
+}
+
async function processPendingMerge(
wex: WalletExecutionContext,
peerInc: WalletPeerPushCredit,
@@ -911,6 +1043,10 @@ async function processPendingMerge(
const { peerPushCreditId } = peerInc;
const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
+ if (isPurseGoneByExpiration(contractTerms.purse_expiration)) {
+ return reconcileExpiredPeerPushCredit(wex, peerInc, contractTerms);
+ }
+
const kycCheckRes = await checkIncomingAmountLegalUnderKycBalanceThreshold(
wex,
peerInc.exchangeBaseUrl,
@@ -983,6 +1119,9 @@ async function processPendingMerge(
};
logger.trace(`merge request: ${j2s(mergeReq)}`);
+ if (isPurseGoneByExpiration(contractTerms.purse_expiration)) {
+ return reconcileExpiredPeerPushCredit(wex, peerInc, contractTerms);
+ }
const mergeResp = await exchangeClient.postPurseMerge(
peerInc.pursePub,
mergeReq,
@@ -1089,43 +1228,12 @@ async function processPendingMerge(
assertUnreachable(mergeResp);
}
- const withdrawalGroupPrep = await internalPrepareCreateWithdrawalGroup(wex, {
+ return continuePeerPushCreditAfterMerge(
+ wex,
+ peerInc,
amount,
- wgInfo: {
- withdrawalType: WithdrawalRecordType.PeerPushCredit,
- },
- forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
- exchangeBaseUrl: peerInc.exchangeBaseUrl,
- reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
- reserveKeyPair: {
- priv: mergeReserveInfo.reservePriv,
- pub: mergeReserveInfo.reservePub,
- },
- });
-
- await wex.runWalletDbTx(async (tx) => {
- const [peerInc, h] = await ctx.getRecordHandle(tx);
- if (!peerInc) {
- return undefined;
- }
- let wgCreateRes: PerformCreateWithdrawalGroupResult | undefined = undefined;
- switch (peerInc.status) {
- case PeerPushCreditStatus.PendingMerge:
- case PeerPushCreditStatus.PendingMergeKycRequired: {
- peerInc.status = PeerPushCreditStatus.PendingWithdrawing;
- wgCreateRes = await internalPerformCreateWithdrawalGroup(
- wex,
- tx,
- withdrawalGroupPrep,
- );
- peerInc.withdrawalGroupId =
- wgCreateRes.withdrawalGroup.withdrawalGroupId;
- break;
- }
- }
- await h.update(peerInc, "merge-done");
- });
- return TaskRunResult.backoff();
+ mergeReserveInfo,
+ );
}
async function processPendingWithdrawing(
@@ -1301,9 +1409,6 @@ export async function processPeerPushCredit(
wex: WalletExecutionContext,
peerPushCreditId: string,
): Promise<TaskRunResult> {
- if (!wex.ws.networkAvailable) {
- return TaskRunResult.networkRequired();
- }
const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
const { peerInc, contractTerms } = await wex.runWalletDbTx(async (tx) => {
@@ -1337,6 +1442,59 @@ export async function processPeerPushCredit(
);
switch (peerInc.status) {
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.Aborted:
+ case PeerPushCreditStatus.Failed:
+ case PeerPushCreditStatus.FailedKycHardLimit:
+ case PeerPushCreditStatus.Expired:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ case PeerPushCreditStatus.SuspendedBalanceKycRequired:
+ case PeerPushCreditStatus.SuspendedBalanceKycInit:
+ return TaskRunResult.finished();
+ default:
+ break;
+ }
+
+ const purseExpiration = contractTerms.purse_expiration;
+ if (
+ peerInc.status === PeerPushCreditStatus.DialogProposed &&
+ isPurseGoneByExpiration(purseExpiration)
+ ) {
+ await wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (rec?.status !== PeerPushCreditStatus.DialogProposed) {
+ return;
+ }
+ rec.status = PeerPushCreditStatus.Expired;
+ await h.update(rec, "dialog-expired");
+ });
+ return TaskRunResult.finished();
+ }
+
+ if (!wex.ws.networkAvailable) {
+ if (
+ peerInc.status === PeerPushCreditStatus.DialogProposed ||
+ isActivePreMergePeerPushCreditStatus(peerInc.status)
+ ) {
+ if (!isPurseGoneByExpiration(purseExpiration)) {
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.fromProtocolTimestamp(purseExpiration),
+ );
+ }
+ }
+ return TaskRunResult.networkRequired();
+ }
+
+ if (
+ isActivePreMergePeerPushCreditStatus(peerInc.status) &&
+ isPurseGoneByExpiration(purseExpiration)
+ ) {
+ return reconcileExpiredPeerPushCredit(wex, peerInc, contractTerms);
+ }
+
+ switch (peerInc.status) {
case PeerPushCreditStatus.DialogProposed:
return processPeerPushDebitDialogProposed(wex, peerInc);
case PeerPushCreditStatus.PendingMergeKycRequired:
@@ -1499,6 +1657,21 @@ async function internalConfirmPeerPushCredit(
const peerInc = res.peerInc;
+ if (peerInc.status !== PeerPushCreditStatus.DialogProposed) {
+ return { transactionId: ctx.transactionId };
+ }
+ if (isPurseGoneByExpiration(res.contractTerms.purse_expiration)) {
+ await wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (rec?.status !== PeerPushCreditStatus.DialogProposed) {
+ return;
+ }
+ rec.status = PeerPushCreditStatus.Expired;
+ await h.update(rec, "confirm-expired");
+ });
+ return { transactionId: ctx.transactionId };
+ }
+
const exchange = await fetchFreshExchangeWithRetryNow(
wex,
peerInc.exchangeBaseUrl,
@@ -1524,8 +1697,13 @@ async function internalConfirmPeerPushCredit(
}
switch (rec.status) {
case PeerPushCreditStatus.DialogProposed: {
- rec.status = PeerPushCreditStatus.PendingMerge;
- await h.update(rec, "confirm");
+ if (isPurseGoneByExpiration(res.contractTerms.purse_expiration)) {
+ rec.status = PeerPushCreditStatus.Expired;
+ await h.update(rec, "confirm-expired");
+ } else {
+ rec.status = PeerPushCreditStatus.PendingMerge;
+ await h.update(rec, "confirm");
+ }
break;
}
}
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.test.ts
@@ -5,12 +5,20 @@ import {
TalerProtocolTimestamp,
setGlobalLogLevelFromString,
} from "@gnu-taler/taler-util";
-import { PeerPushDebitStatus } from "./db/records.js";
+import { TaskRunResultType } from "./common.js";
+import {
+ PeerPushDebitStatus,
+ WalletPeerPushDebit,
+ timestampProtocolToDb,
+} from "./db/records.js";
+import { WalletDbTransaction } from "./db/transaction.js";
import {
PeerPushDebitTransactionContext,
decodePeerPushDebitQuote,
encodePeerPushDebitQuote,
+ processPeerPushDebit,
} from "./pay-peer-push-debit.js";
+import { WalletExecutionContext } from "./wallet.js";
test("peer push debit quote round-trips reviewed values", () => {
const quote = {
@@ -101,3 +109,79 @@ test("peer push debit metadata logging excludes private capabilities", async ()
assert.ok(!output.includes(privateValue), privateValue);
}
});
+
+async function processOfflinePushDebit(
+ status: PeerPushDebitStatus,
+ expiration: TalerProtocolTimestamp,
+): Promise<{ record: WalletPeerPushDebit; resultType: TaskRunResultType }> {
+ let record: WalletPeerPushDebit = {
+ amount: "TESTKUDOS:1",
+ totalCost: "TESTKUDOS:1",
+ contractPriv: "contract-priv",
+ contractPub: "contract-pub",
+ contractTermsHash: "contract-hash",
+ contractEncNonce: "nonce",
+ exchangeBaseUrl: "https://exchange.example/",
+ mergePriv: "merge-priv",
+ mergePub: "merge-pub",
+ purseExpiration: timestampProtocolToDb(expiration),
+ pursePriv: "purse-priv",
+ pursePub: "purse-pub",
+ status,
+ timestampCreated: 1 as any,
+ };
+ const tx = {
+ async getPeerPushDebit(): Promise<WalletPeerPushDebit> {
+ return record;
+ },
+ async upsertPeerPushDebit(updated: WalletPeerPushDebit): Promise<void> {
+ record = updated;
+ },
+ async upsertTransactionMeta(): Promise<void> {},
+ notify(): void {},
+ } as unknown as WalletDbTransaction;
+ const wex = {
+ ws: { networkAvailable: false },
+ async runWalletDbTx<T>(
+ f: (transaction: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return f(tx);
+ },
+ } as unknown as WalletExecutionContext;
+ const result = await processPeerPushDebit(wex, record.pursePub);
+ return { record, resultType: result.type };
+}
+
+test("offline push debit schedules its deadline and enters expiration cleanup", async () => {
+ const waiting = await processOfflinePushDebit(
+ PeerPushDebitStatus.PendingCreatePurse,
+ TalerProtocolTimestamp.fromSeconds(4_000_000_000),
+ );
+ assert.strictEqual(
+ waiting.record.status,
+ PeerPushDebitStatus.PendingCreatePurse,
+ );
+ assert.strictEqual(waiting.resultType, TaskRunResultType.ScheduleLater);
+
+ const expired = await processOfflinePushDebit(
+ PeerPushDebitStatus.PendingCreatePurse,
+ TalerProtocolTimestamp.fromSeconds(1),
+ );
+ assert.strictEqual(
+ expired.record.status,
+ PeerPushDebitStatus.ExpiredDeletePurse,
+ );
+ assert.strictEqual(expired.resultType, TaskRunResultType.Progress);
+});
+
+test("an expired suspended push debit remains suspended", async () => {
+ const suspended = await processOfflinePushDebit(
+ PeerPushDebitStatus.SuspendedCreatePurse,
+ TalerProtocolTimestamp.fromSeconds(1),
+ );
+ assert.strictEqual(
+ suspended.record.status,
+ PeerPushDebitStatus.SuspendedCreatePurse,
+ );
+ assert.strictEqual(suspended.resultType, TaskRunResultType.Finished);
+});
diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts
@@ -784,6 +784,36 @@ function purseGoneStatus(rec: WalletPeerPushDebit): PeerPushDebitStatus {
: PeerPushDebitStatus.AbortingDeletePurse;
}
+async function beginPeerPushDebitExpirationCleanup(
+ wex: WalletExecutionContext,
+ peerPushInitiation: WalletPeerPushDebit,
+ reason: string,
+): Promise<void> {
+ const ctx = new PeerPushDebitTransactionContext(
+ wex,
+ peerPushInitiation.pursePub,
+ );
+ await wex.runWalletDbTx(async (tx) => {
+ const [rec, h] = await ctx.getRecordHandle(tx);
+ if (!rec) {
+ return;
+ }
+ switch (rec.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.PendingReady:
+ rec.status = PeerPushDebitStatus.ExpiredDeletePurse;
+ await h.update(rec, reason);
+ break;
+ default:
+ break;
+ }
+ });
+}
+
+function peerPushDebitExpired(rec: WalletPeerPushDebit): boolean {
+ return isPurseGoneByExpiration(timestampProtocolFromDb(rec.purseExpiration));
+}
+
async function processPeerPushDebitCreateReserve(
wex: WalletExecutionContext,
peerPushInitiation: WalletPeerPushDebit,
@@ -794,6 +824,15 @@ async function processPeerPushDebitCreateReserve(
logger.trace(`processing ${ctx.transactionId} pending(create-reserve)`);
+ if (peerPushDebitExpired(peerPushInitiation)) {
+ await beginPeerPushDebitExpirationCleanup(
+ wex,
+ peerPushInitiation,
+ "create-purse-expired",
+ );
+ return TaskRunResult.progress();
+ }
+
const contractTermsRecord = await wex.runWalletDbTx(async (tx) => {
return tx.getContractTerms(contractTermsHash);
});
@@ -894,6 +933,14 @@ async function processPeerPushDebitCreateReserve(
const exchangeClient = walletExchangeClient(exchangeBaseUrl, wex);
for (let i = 0; i < coins.length; i += maxBatchSize) {
+ if (peerPushDebitExpired(peerPushInitiation)) {
+ await beginPeerPushDebitExpirationCleanup(
+ wex,
+ peerPushInitiation,
+ "deposit-purse-expired",
+ );
+ return TaskRunResult.progress();
+ }
const batchSize = Math.min(maxBatchSize, coins.length - i);
const batchCoins = coins.slice(i, i + batchSize);
@@ -947,6 +994,16 @@ async function processPeerPushDebitCreateReserve(
);
case HttpStatusCode.TooEarly:
return TaskRunResult.backoff();
+ case TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_BEFORE_NOW:
+ await beginPeerPushDebitExpirationCleanup(
+ wex,
+ peerPushInitiation,
+ "create-purse-expiration-rejected",
+ );
+ return TaskRunResult.progress();
+ case TalerErrorCode.EXCHANGE_PURSE_CREATE_EXPIRATION_IS_NEVER:
+ await ctx.failTransaction(peerPushInitiation.status, resp.detail);
+ return TaskRunResult.finished();
default:
assertUnreachable(resp);
}
@@ -1260,10 +1317,6 @@ export async function processPeerPushDebit(
wex: WalletExecutionContext,
pursePub: string,
): Promise<TaskRunResult> {
- if (!wex.ws.networkAvailable) {
- return TaskRunResult.networkRequired();
- }
-
const peerPushInitiation = await wex.runWalletDbTx(async (tx) => {
return tx.getPeerPushDebit(pursePub);
});
@@ -1273,6 +1326,50 @@ export async function processPeerPushDebit(
switch (peerPushInitiation.status) {
case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.PendingReady:
+ if (peerPushDebitExpired(peerPushInitiation)) {
+ await beginPeerPushDebitExpirationCleanup(
+ wex,
+ peerPushInitiation,
+ "purse-expired",
+ );
+ return TaskRunResult.progress();
+ }
+ break;
+ default:
+ break;
+ }
+
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.SuspendedCreatePurse:
+ case PeerPushDebitStatus.SuspendedReady:
+ case PeerPushDebitStatus.SuspendedAbortingDeletePurse:
+ case PeerPushDebitStatus.SuspendedExpiredDeletePurse:
+ case PeerPushDebitStatus.Done:
+ case PeerPushDebitStatus.Aborted:
+ case PeerPushDebitStatus.Failed:
+ case PeerPushDebitStatus.Expired:
+ return TaskRunResult.finished();
+ default:
+ break;
+ }
+
+ if (!wex.ws.networkAvailable) {
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
+ case PeerPushDebitStatus.PendingReady:
+ return TaskRunResult.runAgainAt(
+ AbsoluteTime.fromProtocolTimestamp(
+ timestampProtocolFromDb(peerPushInitiation.purseExpiration),
+ ),
+ );
+ default:
+ return TaskRunResult.networkRequired();
+ }
+ }
+
+ switch (peerPushInitiation.status) {
+ case PeerPushDebitStatus.PendingCreatePurse:
return processPeerPushDebitCreateReserve(wex, peerPushInitiation);
case PeerPushDebitStatus.PendingReady:
return processPeerPushDebitReady(wex, peerPushInitiation);
@@ -1306,6 +1403,19 @@ export async function initiatePeerPushDebit(
);
const currency = Amounts.currencyOf(instructedAmount);
+ const requestedExpiration = req.partialContractTerms.purse_expiration;
+ if (
+ requestedExpiration &&
+ (TalerProtocolTimestamp.isNever(requestedExpiration) ||
+ isPurseGoneByExpiration(requestedExpiration))
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "partialContractTerms.purse_expiration" },
+ "peer push payment expiration must be in the future and finite",
+ );
+ }
+
let quote: PeerPushDebitQuoteV1 | undefined;
if (req.peerPushDebitQuote) {
try {
@@ -1438,6 +1548,17 @@ export async function initiatePeerPushDebit(
);
if (
+ TalerProtocolTimestamp.isNever(myExpiration) ||
+ isPurseGoneByExpiration(myExpiration)
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "partialContractTerms.purse_expiration" },
+ "peer push payment expiration must be in the future and finite",
+ );
+ }
+
+ if (
quote &&
(timestampAfter(myExpiration, quote.maxExpirationDate) ||
timestampAfter(myExpiration, coinSelRes.result.maxExpirationDate))