taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/taler-harness/src/integrationtests/test-peer-pull.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-peer-push.ts | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/exchange-client.ts | 12++++++++++++
Mpackages/taler-util/src/http-client/status-handling.test.ts | 15+++++++++++++++
Mpackages/taler-wallet-core/src/db/records.ts | 4++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.test.ts | 25+++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 206++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 157++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 262++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.test.ts | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
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))