/*
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
*/
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,
requireExchangeTosAcceptedOrThrow,
} 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 {
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 {
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 {
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 {
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 {
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 {
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;
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,
);
if (wi.selectedDenoms.selectedDenoms.length === 0) {
throw Error(
`unable to prepare push credit from ${exchangeBaseUrl}, can't select denominations for instructed amount (${purseStatus.balance}`,
);
}
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 {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushCreditId,
});
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
logger.info(`kyc url ${url.href}`);
const kycStatusRes = await wex.ws.runLongpollQueueing(
wex,
url.hostname,
async (timeoutMs) => {
url.searchParams.set("timeout_ms", `${timeoutMs}`);
return 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 {
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 {
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 {
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 {
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,
`not contract terms for peer push ${peerPushCreditId}`,
);
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 {
// 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}`);
const peerInc = await wex.db.runReadWriteTx(
{ storeNames: ["contractTerms", "peerPushCredit"] },
async (tx) => {
const rec = await tx.peerPushCredit.get(peerPushCreditId);
if (!rec) {
return;
}
if (rec.status === PeerPushCreditStatus.DialogProposed) {
rec.status = PeerPushCreditStatus.PendingMerge;
}
await tx.peerPushCredit.put(rec);
return rec;
},
);
if (!peerInc) {
throw Error(
`can't accept unknown incoming p2p push payment (${req.transactionId})`,
);
}
const exchange = await fetchFreshExchange(wex, peerInc.exchangeBaseUrl);
requireExchangeTosAcceptedOrThrow(exchange);
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.Retry, TransactionAction.Delete];
case PeerPushCreditStatus.PendingMerge:
return [
TransactionAction.Retry,
TransactionAction.Abort,
TransactionAction.Suspend,
];
case PeerPushCreditStatus.Done:
return [TransactionAction.Delete];
case PeerPushCreditStatus.PendingMergeKycRequired:
return [
TransactionAction.Retry,
TransactionAction.Abort,
TransactionAction.Suspend,
];
case PeerPushCreditStatus.PendingWithdrawing:
return [
TransactionAction.Retry,
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);
}
}