summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/pay-peer-push-credit.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/pay-peer-push-credit.ts')
-rw-r--r--packages/taler-wallet-core/src/pay-peer-push-credit.ts1036
1 files changed, 1036 insertions, 0 deletions
diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
new file mode 100644
index 000000000..93f1a63a7
--- /dev/null
+++ b/packages/taler-wallet-core/src/pay-peer-push-credit.ts
@@ -0,0 +1,1036 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2023 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AcceptPeerPushPaymentResponse,
+ Amounts,
+ ConfirmPeerPushCreditRequest,
+ ContractTermsUtil,
+ ExchangePurseMergeRequest,
+ HttpStatusCode,
+ Logger,
+ NotificationType,
+ PeerContractTerms,
+ PreparePeerPushCreditRequest,
+ PreparePeerPushCreditResponse,
+ TalerErrorCode,
+ TalerPreciseTimestamp,
+ TalerProtocolTimestamp,
+ TransactionAction,
+ TransactionIdStr,
+ TransactionMajorState,
+ TransactionMinorState,
+ TransactionState,
+ TransactionType,
+ WalletAccountMergeFlags,
+ WalletKycUuid,
+ assertUnreachable,
+ checkDbInvariant,
+ codecForAny,
+ codecForExchangeGetContractResponse,
+ codecForPeerContractTerms,
+ codecForWalletKycUuid,
+ decodeCrock,
+ eddsaGetPublic,
+ encodeCrock,
+ getRandomBytes,
+ j2s,
+ makeErrorDetail,
+ parsePayPushUri,
+ talerPaytoFromExchangeReserve,
+} from "@gnu-taler/taler-util";
+import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
+import {
+ PendingTaskType,
+ TaskIdStr,
+ TaskRunResult,
+ TaskRunResultType,
+ TombstoneTag,
+ TransactionContext,
+ constructTaskIdentifier,
+} from "./common.js";
+import {
+ KycPendingInfo,
+ KycUserType,
+ PeerPushCreditStatus,
+ PeerPushPaymentIncomingRecord,
+ WithdrawalGroupStatus,
+ WithdrawalRecordType,
+ timestampPreciseToDb,
+} from "./db.js";
+import { fetchFreshExchange } from "./exchanges.js";
+import {
+ codecForExchangePurseStatus,
+ getMergeReserveInfo,
+} from "./pay-peer-common.js";
+import {
+ TransitionInfo,
+ constructTransactionIdentifier,
+ notifyTransition,
+ parseTransactionIdentifier,
+} from "./transactions.js";
+import { WalletExecutionContext } from "./wallet.js";
+import {
+ PerformCreateWithdrawalGroupResult,
+ getExchangeWithdrawalInfo,
+ internalPerformCreateWithdrawalGroup,
+ internalPrepareCreateWithdrawalGroup,
+ waitWithdrawalFinal,
+} from "./withdraw.js";
+
+const logger = new Logger("pay-peer-push-credit.ts");
+
+export class PeerPushCreditTransactionContext implements TransactionContext {
+ readonly transactionId: TransactionIdStr;
+ readonly taskId: TaskIdStr;
+
+ constructor(
+ public wex: WalletExecutionContext,
+ public peerPushCreditId: string,
+ ) {
+ this.transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ this.taskId = constructTaskIdentifier({
+ tag: PendingTaskType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ }
+
+ async deleteTransaction(): Promise<void> {
+ const { wex, peerPushCreditId } = this;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["withdrawalGroups", "peerPushCredit", "tombstones"] },
+ async (tx) => {
+ const pushInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushInc) {
+ return;
+ }
+ if (pushInc.withdrawalGroupId) {
+ const withdrawalGroupId = pushInc.withdrawalGroupId;
+ const withdrawalGroupRecord =
+ await tx.withdrawalGroups.get(withdrawalGroupId);
+ if (withdrawalGroupRecord) {
+ await tx.withdrawalGroups.delete(withdrawalGroupId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeleteWithdrawalGroup + ":" + withdrawalGroupId,
+ });
+ }
+ }
+ await tx.peerPushCredit.delete(peerPushCreditId);
+ await tx.tombstones.put({
+ id: TombstoneTag.DeletePeerPushCredit + ":" + peerPushCreditId,
+ });
+ },
+ );
+ return;
+ }
+
+ async suspendTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.SuspendedMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.SuspendedMerge;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ // FIXME: Suspend internal withdrawal transaction!
+ newStatus = PeerPushCreditStatus.SuspendedWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ }
+
+ async abortTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Done:
+ break;
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingMerge:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.PendingWithdrawing:
+ newStatus = PeerPushCreditStatus.Aborted;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async resumeTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ newStatus = PeerPushCreditStatus.PendingMerge;
+ break;
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ newStatus = PeerPushCreditStatus.PendingMergeKycRequired;
+ break;
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ // FIXME: resume underlying "internal-withdrawal" transaction.
+ newStatus = PeerPushCreditStatus.PendingWithdrawing;
+ break;
+ case PeerPushCreditStatus.Aborted:
+ break;
+ case PeerPushCreditStatus.Failed:
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+
+ async failTransaction(): Promise<void> {
+ const { wex, peerPushCreditId, taskId: retryTag, transactionId } = this;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const pushCreditRec = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!pushCreditRec) {
+ logger.warn(`peer push credit ${peerPushCreditId} not found`);
+ return;
+ }
+ let newStatus: PeerPushCreditStatus | undefined = undefined;
+ switch (pushCreditRec.status) {
+ case PeerPushCreditStatus.Done:
+ case PeerPushCreditStatus.Aborted:
+ case PeerPushCreditStatus.Failed:
+ // Already in a final state.
+ return;
+ case PeerPushCreditStatus.DialogProposed:
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ case PeerPushCreditStatus.PendingMerge:
+ case PeerPushCreditStatus.PendingWithdrawing:
+ case PeerPushCreditStatus.SuspendedMerge:
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ newStatus = PeerPushCreditStatus.Failed;
+ break;
+ default:
+ assertUnreachable(pushCreditRec.status);
+ }
+ if (newStatus != null) {
+ const oldTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ pushCreditRec.status = newStatus;
+ const newTxState =
+ computePeerPushCreditTransactionState(pushCreditRec);
+ await tx.peerPushCredit.put(pushCreditRec);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ }
+ return undefined;
+ },
+ );
+ wex.taskScheduler.stopShepherdTask(retryTag);
+ notifyTransition(wex, transactionId, transitionInfo);
+ wex.taskScheduler.startShepherdTask(retryTag);
+ }
+}
+
+export async function preparePeerPushCredit(
+ wex: WalletExecutionContext,
+ req: PreparePeerPushCreditRequest,
+): Promise<PreparePeerPushCreditResponse> {
+ const uri = parsePayPushUri(req.talerUri);
+
+ if (!uri) {
+ throw Error("got invalid taler://pay-push URI");
+ }
+
+ const existing = await wex.db.runReadOnlyTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ const existingPushInc =
+ await tx.peerPushCredit.indexes.byExchangeAndContractPriv.get([
+ uri.exchangeBaseUrl,
+ uri.contractPriv,
+ ]);
+ if (!existingPushInc) {
+ return;
+ }
+ const existingContractTermsRec = await tx.contractTerms.get(
+ existingPushInc.contractTermsHash,
+ );
+ if (!existingContractTermsRec) {
+ throw Error(
+ "contract terms for peer push payment credit not found in database",
+ );
+ }
+ const existingContractTerms = codecForPeerContractTerms().decode(
+ existingContractTermsRec.contractTermsRaw,
+ );
+ return { existingPushInc, existingContractTerms };
+ },
+ );
+
+ if (existing) {
+ return {
+ amount: existing.existingContractTerms.amount,
+ amountEffective: existing.existingPushInc.estimatedAmountEffective,
+ amountRaw: existing.existingContractTerms.amount,
+ contractTerms: existing.existingContractTerms,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: existing.existingPushInc.peerPushCreditId,
+ }),
+ exchangeBaseUrl: existing.existingPushInc.exchangeBaseUrl,
+ };
+ }
+
+ const exchangeBaseUrl = uri.exchangeBaseUrl;
+
+ await fetchFreshExchange(wex, exchangeBaseUrl);
+
+ const contractPriv = uri.contractPriv;
+ const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
+
+ const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
+
+ const contractHttpResp = await wex.http.fetch(getContractUrl.href);
+
+ const contractResp = await readSuccessResponseJsonOrThrow(
+ contractHttpResp,
+ codecForExchangeGetContractResponse(),
+ );
+
+ const pursePub = contractResp.purse_pub;
+
+ const dec = await wex.cryptoApi.decryptContractForMerge({
+ ciphertext: contractResp.econtract,
+ contractPriv: contractPriv,
+ pursePub: pursePub,
+ });
+
+ const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
+
+ const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
+
+ const contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
+
+ const purseStatus = await readSuccessResponseJsonOrThrow(
+ purseHttpResp,
+ codecForExchangePurseStatus(),
+ );
+
+ logger.info(
+ `peer push credit, purse balance ${purseStatus.balance}, contract amount ${contractTerms.amount}`,
+ );
+
+ const peerPushCreditId = encodeCrock(getRandomBytes(32));
+
+ const contractTermsHash = ContractTermsUtil.hashContractTerms(
+ dec.contractTerms,
+ );
+
+ const withdrawalGroupId = encodeCrock(getRandomBytes(32));
+
+ const wi = await getExchangeWithdrawalInfo(
+ wex,
+ exchangeBaseUrl,
+ Amounts.parseOrThrow(purseStatus.balance),
+ undefined,
+ );
+
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ const rec: PeerPushPaymentIncomingRecord = {
+ peerPushCreditId,
+ contractPriv: contractPriv,
+ exchangeBaseUrl: exchangeBaseUrl,
+ mergePriv: dec.mergePriv,
+ pursePub: pursePub,
+ timestamp: timestampPreciseToDb(TalerPreciseTimestamp.now()),
+ contractTermsHash,
+ status: PeerPushCreditStatus.DialogProposed,
+ withdrawalGroupId,
+ currency: Amounts.currencyOf(purseStatus.balance),
+ estimatedAmountEffective: Amounts.stringify(
+ wi.withdrawalAmountEffective,
+ ),
+ };
+ await tx.peerPushCredit.add(rec);
+ await tx.contractTerms.put({
+ h: contractTermsHash,
+ contractTermsRaw: dec.contractTerms,
+ });
+
+ const newTxState = computePeerPushCreditTransactionState(rec);
+
+ return {
+ oldTxState: {
+ major: TransactionMajorState.None,
+ },
+ newTxState,
+ } satisfies TransitionInfo;
+ },
+ );
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ notifyTransition(wex, transactionId, transitionInfo);
+
+ wex.ws.notify({
+ type: NotificationType.BalanceChange,
+ hintTransactionId: transactionId,
+ });
+
+ return {
+ amount: purseStatus.balance,
+ amountEffective: wi.withdrawalAmountEffective,
+ amountRaw: purseStatus.balance,
+ contractTerms: dec.contractTerms,
+ peerPushCreditId,
+ transactionId,
+ exchangeBaseUrl,
+ };
+}
+
+async function longpollKycStatus(
+ wex: WalletExecutionContext,
+ peerPushCreditId: string,
+ exchangeUrl: string,
+ kycInfo: KycPendingInfo,
+ userType: KycUserType,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+ const url = new URL(
+ `kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
+ exchangeUrl,
+ );
+ url.searchParams.set("timeout_ms", "30000");
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status !== PeerPushCreditStatus.PendingMergeKycRequired) {
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ return { oldTxState, newTxState };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return TaskRunResult.progress();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ // FIXME: Do we have to update the URL here?
+ return TaskRunResult.longpollReturnedPending();
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function processPeerPushCreditKycRequired(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+ kycPending: WalletKycUuid,
+): Promise<TaskRunResult> {
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const { peerPushCreditId } = peerInc;
+
+ const userType = "individual";
+ const url = new URL(
+ `kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ logger.info(`kyc url ${url.href}`);
+ const kycStatusRes = await wex.http.fetch(url.href, {
+ method: "GET",
+ cancellationToken: wex.cancellationToken,
+ });
+
+ if (
+ kycStatusRes.status === HttpStatusCode.Ok ||
+ //FIXME: NoContent is not expected https://docs.taler.net/core/api-exchange.html#post--purses-$PURSE_PUB-merge
+ // remove after the exchange is fixed or clarified
+ kycStatusRes.status === HttpStatusCode.NoContent
+ ) {
+ logger.warn("kyc requested, but already fulfilled");
+ return TaskRunResult.finished();
+ } else if (kycStatusRes.status === HttpStatusCode.Accepted) {
+ const kycStatus = await kycStatusRes.json();
+ logger.info(`kyc status: ${j2s(kycStatus)}`);
+ const { transitionInfo, result } = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit"] },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return {
+ transitionInfo: undefined,
+ result: TaskRunResult.finished(),
+ };
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ peerInc.kycInfo = {
+ paytoHash: kycPending.h_payto,
+ requirementRow: kycPending.requirement_row,
+ };
+ peerInc.kycUrl = kycStatus.kyc_url;
+ peerInc.status = PeerPushCreditStatus.PendingMergeKycRequired;
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ await tx.peerPushCredit.put(peerInc);
+ // We'll remove this eventually! New clients should rely on the
+ // kycUrl field of the transaction, not the error code.
+ const res: TaskRunResult = {
+ type: TaskRunResultType.Error,
+ errorDetail: makeErrorDetail(
+ TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED,
+ {
+ kycUrl: kycStatus.kyc_url,
+ },
+ ),
+ };
+ return {
+ transitionInfo: { oldTxState, newTxState },
+ result: res,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ return result;
+ } else {
+ throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
+ }
+}
+
+async function handlePendingMerge(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+ contractTerms: PeerContractTerms,
+): Promise<TaskRunResult> {
+ const { peerPushCreditId } = peerInc;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ const amount = Amounts.parseOrThrow(contractTerms.amount);
+
+ const mergeReserveInfo = await getMergeReserveInfo(wex, {
+ exchangeBaseUrl: peerInc.exchangeBaseUrl,
+ });
+
+ const mergeTimestamp = TalerProtocolTimestamp.now();
+
+ const reservePayto = talerPaytoFromExchangeReserve(
+ peerInc.exchangeBaseUrl,
+ mergeReserveInfo.reservePub,
+ );
+
+ const sigRes = await wex.cryptoApi.signPurseMerge({
+ contractTermsHash: ContractTermsUtil.hashContractTerms(contractTerms),
+ flags: WalletAccountMergeFlags.MergeFullyPaidPurse,
+ mergePriv: peerInc.mergePriv,
+ mergeTimestamp: mergeTimestamp,
+ purseAmount: Amounts.stringify(amount),
+ purseExpiration: contractTerms.purse_expiration,
+ purseFee: Amounts.stringify(Amounts.zeroOfCurrency(amount.currency)),
+ pursePub: peerInc.pursePub,
+ reservePayto,
+ reservePriv: mergeReserveInfo.reservePriv,
+ });
+
+ const mergePurseUrl = new URL(
+ `purses/${peerInc.pursePub}/merge`,
+ peerInc.exchangeBaseUrl,
+ );
+
+ const mergeReq: ExchangePurseMergeRequest = {
+ payto_uri: reservePayto,
+ merge_timestamp: mergeTimestamp,
+ merge_sig: sigRes.mergeSig,
+ reserve_sig: sigRes.accountSig,
+ };
+
+ const mergeHttpResp = await wex.http.fetch(mergePurseUrl.href, {
+ method: "POST",
+ body: mergeReq,
+ });
+
+ if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
+ const respJson = await mergeHttpResp.json();
+ const kycPending = codecForWalletKycUuid().decode(respJson);
+ logger.info(`kyc uuid response: ${j2s(kycPending)}`);
+ return processPeerPushCreditKycRequired(wex, peerInc, kycPending);
+ }
+
+ logger.trace(`merge request: ${j2s(mergeReq)}`);
+ const res = await readSuccessResponseJsonOrThrow(
+ mergeHttpResp,
+ codecForAny(),
+ );
+ logger.trace(`merge response: ${j2s(res)}`);
+
+ 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,
+ },
+ });
+
+ const txRes = await wex.db.runReadWriteTx(
+ {
+ storeNames: [
+ "contractTerms",
+ "peerPushCredit",
+ "withdrawalGroups",
+ "reserves",
+ "exchanges",
+ "exchangeDetails",
+ ],
+ },
+ async (tx) => {
+ const peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return undefined;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(peerInc);
+ 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 tx.peerPushCredit.put(peerInc);
+ const newTxState = computePeerPushCreditTransactionState(peerInc);
+ return {
+ peerPushCreditTransition: { oldTxState, newTxState },
+ wgCreateRes,
+ };
+ },
+ );
+ // Transaction was committed, now we can emit notifications.
+ if (txRes?.wgCreateRes?.exchangeNotif) {
+ wex.ws.notify(txRes.wgCreateRes.exchangeNotif);
+ }
+ notifyTransition(
+ wex,
+ withdrawalGroupPrep.transactionId,
+ txRes?.wgCreateRes?.transitionInfo,
+ );
+ notifyTransition(wex, transactionId, txRes?.peerPushCreditTransition);
+
+ return TaskRunResult.backoff();
+}
+
+async function handlePendingWithdrawing(
+ wex: WalletExecutionContext,
+ peerInc: PeerPushPaymentIncomingRecord,
+): Promise<TaskRunResult> {
+ if (!peerInc.withdrawalGroupId) {
+ throw Error("invalid db state (withdrawing, but no withdrawal group ID");
+ }
+ await waitWithdrawalFinal(wex, peerInc.withdrawalGroupId);
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId: peerInc.peerPushCreditId,
+ });
+ const wgId = peerInc.withdrawalGroupId;
+ let finished: boolean = false;
+ const transitionInfo = await wex.db.runReadWriteTx(
+ { storeNames: ["peerPushCredit", "withdrawalGroups"] },
+ async (tx) => {
+ const ppi = await tx.peerPushCredit.get(peerInc.peerPushCreditId);
+ if (!ppi) {
+ finished = true;
+ return;
+ }
+ if (ppi.status !== PeerPushCreditStatus.PendingWithdrawing) {
+ finished = true;
+ return;
+ }
+ const oldTxState = computePeerPushCreditTransactionState(ppi);
+ const wg = await tx.withdrawalGroups.get(wgId);
+ if (!wg) {
+ // FIXME: Fail the operation instead?
+ return undefined;
+ }
+ switch (wg.status) {
+ case WithdrawalGroupStatus.Done:
+ finished = true;
+ ppi.status = PeerPushCreditStatus.Done;
+ break;
+ // FIXME: Also handle other final states!
+ }
+ await tx.peerPushCredit.put(ppi);
+ const newTxState = computePeerPushCreditTransactionState(ppi);
+ return {
+ oldTxState,
+ newTxState,
+ };
+ },
+ );
+ notifyTransition(wex, transactionId, transitionInfo);
+ if (finished) {
+ return TaskRunResult.finished();
+ } else {
+ // FIXME: Return indicator that we depend on the other operation!
+ return TaskRunResult.backoff();
+ }
+}
+
+export async function processPeerPushCredit(
+ wex: WalletExecutionContext,
+ peerPushCreditId: string,
+): Promise<TaskRunResult> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let contractTerms: PeerContractTerms | undefined;
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
+ if (ctRec) {
+ contractTerms = ctRec.contractTermsRaw;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${peerPushCreditId})`,
+ );
+ }
+
+ logger.info(
+ `processing peerPushCredit in state ${peerInc.status.toString(16)}`,
+ );
+
+ checkDbInvariant(!!contractTerms);
+
+ switch (peerInc.status) {
+ case PeerPushCreditStatus.PendingMergeKycRequired: {
+ if (!peerInc.kycInfo) {
+ throw Error("invalid state, kycInfo required");
+ }
+ return await longpollKycStatus(
+ wex,
+ peerPushCreditId,
+ peerInc.exchangeBaseUrl,
+ peerInc.kycInfo,
+ "individual",
+ );
+ }
+
+ case PeerPushCreditStatus.PendingMerge:
+ return handlePendingMerge(wex, peerInc, contractTerms);
+
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return handlePendingWithdrawing(wex, peerInc);
+
+ default:
+ return TaskRunResult.finished();
+ }
+}
+
+export async function confirmPeerPushCredit(
+ wex: WalletExecutionContext,
+ req: ConfirmPeerPushCreditRequest,
+): Promise<AcceptPeerPushPaymentResponse> {
+ let peerInc: PeerPushPaymentIncomingRecord | undefined;
+ let peerPushCreditId: string;
+ const parsedTx = parseTransactionIdentifier(req.transactionId);
+ if (!parsedTx) {
+ throw Error("invalid transaction ID");
+ }
+ if (parsedTx.tag !== TransactionType.PeerPushCredit) {
+ throw Error("invalid transaction ID type");
+ }
+ peerPushCreditId = parsedTx.peerPushCreditId;
+
+ logger.trace(`confirming peer-push-credit ${peerPushCreditId}`);
+
+ await wex.db.runReadWriteTx(
+ { storeNames: ["contractTerms", "peerPushCredit"] },
+ async (tx) => {
+ peerInc = await tx.peerPushCredit.get(peerPushCreditId);
+ if (!peerInc) {
+ return;
+ }
+ if (peerInc.status === PeerPushCreditStatus.DialogProposed) {
+ peerInc.status = PeerPushCreditStatus.PendingMerge;
+ }
+ await tx.peerPushCredit.put(peerInc);
+ },
+ );
+
+ if (!peerInc) {
+ throw Error(
+ `can't accept unknown incoming p2p push payment (${req.transactionId})`,
+ );
+ }
+
+ const ctx = new PeerPushCreditTransactionContext(wex, peerPushCreditId);
+
+ wex.taskScheduler.startShepherdTask(ctx.taskId);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.PeerPushCredit,
+ peerPushCreditId,
+ });
+
+ return {
+ transactionId,
+ };
+}
+
+export function computePeerPushCreditTransactionState(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionState {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.Proposed,
+ };
+ case PeerPushCreditStatus.PendingMerge:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.KycRequired,
+ };
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.SuspendedMerge:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Merge,
+ };
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.MergeKycRequired,
+ };
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return {
+ major: TransactionMajorState.Suspended,
+ minor: TransactionMinorState.Withdraw,
+ };
+ case PeerPushCreditStatus.Aborted:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PeerPushCreditStatus.Failed:
+ return {
+ major: TransactionMajorState.Failed,
+ };
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}
+
+export function computePeerPushCreditTransactionActions(
+ pushCreditRecord: PeerPushPaymentIncomingRecord,
+): TransactionAction[] {
+ switch (pushCreditRecord.status) {
+ case PeerPushCreditStatus.DialogProposed:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMerge:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.Done:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.PendingMergeKycRequired:
+ return [TransactionAction.Abort, TransactionAction.Suspend];
+ case PeerPushCreditStatus.PendingWithdrawing:
+ return [TransactionAction.Suspend, TransactionAction.Fail];
+ case PeerPushCreditStatus.SuspendedMerge:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedMergeKycRequired:
+ return [TransactionAction.Resume, TransactionAction.Abort];
+ case PeerPushCreditStatus.SuspendedWithdrawing:
+ return [TransactionAction.Resume, TransactionAction.Fail];
+ case PeerPushCreditStatus.Aborted:
+ return [TransactionAction.Delete];
+ case PeerPushCreditStatus.Failed:
+ return [TransactionAction.Delete];
+ default:
+ assertUnreachable(pushCreditRecord.status);
+ }
+}