/*
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 {
AbsoluteTime,
Amounts,
CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse,
ContractTermsUtil,
ExchangeReservePurseRequest,
HttpStatusCode,
InitiatePeerPullCreditRequest,
InitiatePeerPullCreditResponse,
Logger,
NotificationType,
PeerContractTerms,
TalerErrorCode,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TalerUriAction,
TransactionAction,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
WalletAccountMergeFlags,
WalletKycUuid,
assertUnreachable,
checkDbInvariant,
codecForAny,
codecForWalletKycUuid,
encodeCrock,
getRandomBytes,
j2s,
makeErrorDetail,
stringifyTalerUri,
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,
PeerPullCreditRecord,
PeerPullPaymentCreditStatus,
WithdrawalGroupStatus,
WithdrawalRecordType,
timestampOptionalPreciseFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
} from "./db.js";
import { fetchFreshExchange } from "./exchanges.js";
import {
codecForExchangePurseStatus,
getMergeReserveInfo,
} from "./pay-peer-common.js";
import {
constructTransactionIdentifier,
notifyTransition,
} from "./transactions.js";
import { WalletExecutionContext } from "./wallet.js";
import {
getExchangeWithdrawalInfo,
internalCreateWithdrawalGroup,
waitWithdrawalFinal,
} from "./withdraw.js";
const logger = new Logger("pay-peer-pull-credit.ts");
export class PeerPullCreditTransactionContext implements TransactionContext {
readonly transactionId: TransactionIdStr;
readonly taskId: TaskIdStr;
constructor(
public wex: WalletExecutionContext,
public pursePub: string,
) {
this.taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
this.transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
}
async deleteTransaction(): Promise {
const { wex: ws, pursePub } = this;
await ws.db.runReadWriteTx(
{ storeNames: ["withdrawalGroups", "peerPullCredit", "tombstones"] },
async (tx) => {
const pullIni = await tx.peerPullCredit.get(pursePub);
if (!pullIni) {
return;
}
if (pullIni.withdrawalGroupId) {
const withdrawalGroupId = pullIni.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.peerPullCredit.delete(pursePub);
await tx.tombstones.put({
id: TombstoneTag.DeletePeerPullCredit + ":" + pursePub,
});
},
);
return;
}
async suspendTransaction(): Promise {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
newStatus = PeerPullPaymentCreditStatus.SuspendedCreatePurse;
break;
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
newStatus = PeerPullPaymentCreditStatus.SuspendedMergeKycRequired;
break;
case PeerPullPaymentCreditStatus.PendingWithdrawing:
newStatus = PeerPullPaymentCreditStatus.SuspendedWithdrawing;
break;
case PeerPullPaymentCreditStatus.PendingReady:
newStatus = PeerPullPaymentCreditStatus.SuspendedReady;
break;
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
newStatus =
PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse;
break;
case PeerPullPaymentCreditStatus.Done:
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
case PeerPullPaymentCreditStatus.SuspendedReady:
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
case PeerPullPaymentCreditStatus.Aborted:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.Expired:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState =
computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState =
computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullCredit.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
wex.taskScheduler.stopShepherdTask(retryTag);
notifyTransition(wex, transactionId, transitionInfo);
}
async failTransaction(): Promise {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
case PeerPullPaymentCreditStatus.PendingWithdrawing:
case PeerPullPaymentCreditStatus.PendingReady:
case PeerPullPaymentCreditStatus.Done:
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
case PeerPullPaymentCreditStatus.SuspendedReady:
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
case PeerPullPaymentCreditStatus.Aborted:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.Expired:
break;
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
newStatus = PeerPullPaymentCreditStatus.Failed;
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState =
computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState =
computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullCredit.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.stopShepherdTask(retryTag);
}
async resumeTransaction(): Promise {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
case PeerPullPaymentCreditStatus.PendingWithdrawing:
case PeerPullPaymentCreditStatus.PendingReady:
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
case PeerPullPaymentCreditStatus.Done:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.Expired:
case PeerPullPaymentCreditStatus.Aborted:
break;
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
newStatus = PeerPullPaymentCreditStatus.PendingCreatePurse;
break;
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
newStatus = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
break;
case PeerPullPaymentCreditStatus.SuspendedReady:
newStatus = PeerPullPaymentCreditStatus.PendingReady;
break;
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
newStatus = PeerPullPaymentCreditStatus.PendingWithdrawing;
break;
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState =
computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState =
computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullCredit.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(retryTag);
}
async abortTransaction(): Promise {
const { wex, pursePub, taskId: retryTag, transactionId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const pullCreditRec = await tx.peerPullCredit.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentCreditStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
break;
case PeerPullPaymentCreditStatus.PendingWithdrawing:
throw Error("can't abort anymore");
case PeerPullPaymentCreditStatus.PendingReady:
newStatus = PeerPullPaymentCreditStatus.AbortingDeletePurse;
break;
case PeerPullPaymentCreditStatus.Done:
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
case PeerPullPaymentCreditStatus.SuspendedReady:
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
case PeerPullPaymentCreditStatus.Aborted:
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.Expired:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState =
computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState =
computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullCredit.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
wex.taskScheduler.stopShepherdTask(retryTag);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(retryTag);
}
}
async function queryPurseForPeerPullCredit(
wex: WalletExecutionContext,
pullIni: PeerPullCreditRecord,
): Promise {
const purseDepositUrl = new URL(
`purses/${pullIni.pursePub}/deposit`,
pullIni.exchangeBaseUrl,
);
purseDepositUrl.searchParams.set("timeout_ms", "30000");
logger.info(`querying purse status via ${purseDepositUrl.href}`);
const resp = await wex.http.fetch(purseDepositUrl.href, {
timeout: { d_ms: 60000 },
cancellationToken: wex.cancellationToken,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullIni.pursePub,
});
logger.info(`purse status code: HTTP ${resp.status}`);
switch (resp.status) {
case HttpStatusCode.Gone: {
// Exchange says that purse doesn't exist anymore => expired!
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!finPi) {
logger.warn("peerPullCredit not found anymore");
return;
}
const oldTxState = computePeerPullCreditTransactionState(finPi);
if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
finPi.status = PeerPullPaymentCreditStatus.Expired;
}
await tx.peerPullCredit.put(finPi);
const newTxState = computePeerPullCreditTransactionState(finPi);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.backoff();
}
case HttpStatusCode.NotFound:
// FIXME: Maybe check error code? 404 could also mean something else.
return TaskRunResult.longpollReturnedPending();
}
const result = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangePurseStatus(),
);
logger.trace(`purse status: ${j2s(result)}`);
const depositTimestamp = result.deposit_timestamp;
if (!depositTimestamp || TalerProtocolTimestamp.isNever(depositTimestamp)) {
logger.info("purse not ready yet (no deposit)");
return TaskRunResult.backoff();
}
const reserve = await wex.db.runReadOnlyTx(
{ storeNames: ["reserves"] },
async (tx) => {
return await tx.reserves.get(pullIni.mergeReserveRowId);
},
);
if (!reserve) {
throw Error("reserve for peer pull credit not found in wallet DB");
}
await internalCreateWithdrawalGroup(wex, {
amount: Amounts.parseOrThrow(pullIni.amount),
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPullCredit,
contractPriv: pullIni.contractPriv,
},
forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
exchangeBaseUrl: pullIni.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
reserveKeyPair: {
priv: reserve.reservePriv,
pub: reserve.reservePub,
},
});
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const finPi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!finPi) {
logger.warn("peerPullCredit not found anymore");
return;
}
const oldTxState = computePeerPullCreditTransactionState(finPi);
if (finPi.status === PeerPullPaymentCreditStatus.PendingReady) {
finPi.status = PeerPullPaymentCreditStatus.PendingWithdrawing;
}
await tx.peerPullCredit.put(finPi);
const newTxState = computePeerPullCreditTransactionState(finPi);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.backoff();
}
async function longpollKycStatus(
wex: WalletExecutionContext,
pursePub: string,
exchangeUrl: string,
kycInfo: KycPendingInfo,
userType: KycUserType,
): Promise {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
const url = new URL(
`kyc-check/${kycInfo.requirementRow}/${kycInfo.paytoHash}/${userType}`,
exchangeUrl,
);
url.searchParams.set("timeout_ms", "10000");
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: ["peerPullCredit"] },
async (tx) => {
const peerIni = await tx.peerPullCredit.get(pursePub);
if (!peerIni) {
return;
}
if (
peerIni.status !== PeerPullPaymentCreditStatus.PendingMergeKycRequired
) {
return;
}
const oldTxState = computePeerPullCreditTransactionState(peerIni);
peerIni.status = PeerPullPaymentCreditStatus.PendingCreatePurse;
const newTxState = computePeerPullCreditTransactionState(peerIni);
await tx.peerPullCredit.put(peerIni);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.progress();
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
return TaskRunResult.longpollReturnedPending();
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
}
async function processPeerPullCreditAbortingDeletePurse(
wex: WalletExecutionContext,
peerPullIni: PeerPullCreditRecord,
): Promise {
const { pursePub, pursePriv } = peerPullIni;
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
const sigResp = await wex.cryptoApi.signDeletePurse({
pursePriv,
});
const purseUrl = new URL(`purses/${pursePub}`, peerPullIni.exchangeBaseUrl);
const resp = await wex.http.fetch(purseUrl.href, {
method: "DELETE",
headers: {
"taler-purse-signature": sigResp.sig,
},
cancellationToken: wex.cancellationToken,
});
logger.info(`deleted purse with response status ${resp.status}`);
const transitionInfo = await wex.db.runReadWriteTx(
{
storeNames: [
"peerPullCredit",
"refreshGroups",
"denominations",
"coinAvailability",
"coins",
],
},
async (tx) => {
const ppiRec = await tx.peerPullCredit.get(pursePub);
if (!ppiRec) {
return undefined;
}
if (ppiRec.status !== PeerPullPaymentCreditStatus.AbortingDeletePurse) {
return undefined;
}
const oldTxState = computePeerPullCreditTransactionState(ppiRec);
ppiRec.status = PeerPullPaymentCreditStatus.Aborted;
await tx.peerPullCredit.put(ppiRec);
const newTxState = computePeerPullCreditTransactionState(ppiRec);
return {
oldTxState,
newTxState,
};
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.backoff();
}
async function handlePeerPullCreditWithdrawing(
wex: WalletExecutionContext,
pullIni: PeerPullCreditRecord,
): Promise {
if (!pullIni.withdrawalGroupId) {
throw Error("invalid db state (withdrawing, but no withdrawal group ID");
}
await waitWithdrawalFinal(wex, pullIni.withdrawalGroupId);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullIni.pursePub,
});
const wgId = pullIni.withdrawalGroupId;
let finished: boolean = false;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit", "withdrawalGroups"] },
async (tx) => {
const ppi = await tx.peerPullCredit.get(pullIni.pursePub);
if (!ppi) {
finished = true;
return;
}
if (ppi.status !== PeerPullPaymentCreditStatus.PendingWithdrawing) {
finished = true;
return;
}
const oldTxState = computePeerPullCreditTransactionState(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 = PeerPullPaymentCreditStatus.Done;
break;
// FIXME: Also handle other final states!
}
await tx.peerPullCredit.put(ppi);
const newTxState = computePeerPullCreditTransactionState(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();
}
}
async function handlePeerPullCreditCreatePurse(
wex: WalletExecutionContext,
pullIni: PeerPullCreditRecord,
): Promise {
const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
const pursePub = pullIni.pursePub;
const mergeReserve = await wex.db.runReadOnlyTx(
{ storeNames: ["reserves"] },
async (tx) => {
return tx.reserves.get(pullIni.mergeReserveRowId);
},
);
if (!mergeReserve) {
throw Error("merge reserve for peer pull payment not found in database");
}
const contractTermsRecord = await wex.db.runReadOnlyTx(
{ storeNames: ["contractTerms"] },
async (tx) => {
return tx.contractTerms.get(pullIni.contractTermsHash);
},
);
if (!contractTermsRecord) {
throw Error("contract terms for peer pull payment not found in database");
}
const contractTerms: PeerContractTerms = contractTermsRecord.contractTermsRaw;
const reservePayto = talerPaytoFromExchangeReserve(
pullIni.exchangeBaseUrl,
mergeReserve.reservePub,
);
const econtractResp = await wex.cryptoApi.encryptContractForDeposit({
contractPriv: pullIni.contractPriv,
contractPub: pullIni.contractPub,
contractTerms: contractTermsRecord.contractTermsRaw,
pursePriv: pullIni.pursePriv,
pursePub: pullIni.pursePub,
nonce: pullIni.contractEncNonce,
});
const mergeTimestamp = timestampPreciseFromDb(pullIni.mergeTimestamp);
const purseExpiration = contractTerms.purse_expiration;
const sigRes = await wex.cryptoApi.signReservePurseCreate({
contractTermsHash: pullIni.contractTermsHash,
flags: WalletAccountMergeFlags.CreateWithPurseFee,
mergePriv: pullIni.mergePriv,
mergeTimestamp: TalerPreciseTimestamp.round(mergeTimestamp),
purseAmount: pullIni.amount,
purseExpiration: purseExpiration,
purseFee: purseFee,
pursePriv: pullIni.pursePriv,
pursePub: pullIni.pursePub,
reservePayto,
reservePriv: mergeReserve.reservePriv,
});
const reservePurseReqBody: ExchangeReservePurseRequest = {
merge_sig: sigRes.mergeSig,
merge_timestamp: TalerPreciseTimestamp.round(mergeTimestamp),
h_contract_terms: pullIni.contractTermsHash,
merge_pub: pullIni.mergePub,
min_age: 0,
purse_expiration: purseExpiration,
purse_fee: purseFee,
purse_pub: pullIni.pursePub,
purse_sig: sigRes.purseSig,
purse_value: pullIni.amount,
reserve_sig: sigRes.accountSig,
econtract: econtractResp.econtract,
};
logger.info(`reserve purse request: ${j2s(reservePurseReqBody)}`);
const reservePurseMergeUrl = new URL(
`reserves/${mergeReserve.reservePub}/purse`,
pullIni.exchangeBaseUrl,
);
const httpResp = await wex.http.fetch(reservePurseMergeUrl.href, {
method: "POST",
body: reservePurseReqBody,
cancellationToken: wex.cancellationToken,
});
if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
const respJson = await httpResp.json();
const kycPending = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(kycPending)}`);
return processPeerPullCreditKycRequired(wex, pullIni, kycPending);
}
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
logger.info(`reserve merge response: ${j2s(resp)}`);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullIni.pursePub,
});
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
const pi2 = await tx.peerPullCredit.get(pursePub);
if (!pi2) {
return;
}
const oldTxState = computePeerPullCreditTransactionState(pi2);
pi2.status = PeerPullPaymentCreditStatus.PendingReady;
await tx.peerPullCredit.put(pi2);
const newTxState = computePeerPullCreditTransactionState(pi2);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, transactionId, transitionInfo);
return TaskRunResult.backoff();
}
export async function processPeerPullCredit(
wex: WalletExecutionContext,
pursePub: string,
): Promise {
const pullIni = await wex.db.runReadOnlyTx(
{ storeNames: ["peerPullCredit"] },
async (tx) => {
return tx.peerPullCredit.get(pursePub);
},
);
if (!pullIni) {
throw Error("peer pull payment initiation not found in database");
}
const retryTag = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
switch (pullIni.status) {
case PeerPullPaymentCreditStatus.Done: {
return TaskRunResult.finished();
}
case PeerPullPaymentCreditStatus.PendingReady:
return queryPurseForPeerPullCredit(wex, pullIni);
case PeerPullPaymentCreditStatus.PendingMergeKycRequired: {
if (!pullIni.kycInfo) {
throw Error("invalid state, kycInfo required");
}
return await longpollKycStatus(
wex,
pursePub,
pullIni.exchangeBaseUrl,
pullIni.kycInfo,
"individual",
);
}
case PeerPullPaymentCreditStatus.PendingCreatePurse:
return handlePeerPullCreditCreatePurse(wex, pullIni);
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
return await processPeerPullCreditAbortingDeletePurse(wex, pullIni);
case PeerPullPaymentCreditStatus.PendingWithdrawing:
return handlePeerPullCreditWithdrawing(wex, pullIni);
case PeerPullPaymentCreditStatus.Aborted:
case PeerPullPaymentCreditStatus.Failed:
case PeerPullPaymentCreditStatus.Expired:
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
case PeerPullPaymentCreditStatus.SuspendedReady:
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
break;
default:
assertUnreachable(pullIni.status);
}
return TaskRunResult.finished();
}
async function processPeerPullCreditKycRequired(
wex: WalletExecutionContext,
peerIni: PeerPullCreditRecord,
kycPending: WalletKycUuid,
): Promise {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: peerIni.pursePub,
});
const { pursePub } = peerIni;
const userType = "individual";
const url = new URL(
`kyc-check/${kycPending.requirement_row}/${kycPending.h_payto}/${userType}`,
peerIni.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.backoff();
} 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: ["peerPullCredit"] },
async (tx) => {
const peerInc = await tx.peerPullCredit.get(pursePub);
if (!peerInc) {
return {
transitionInfo: undefined,
result: TaskRunResult.finished(),
};
}
const oldTxState = computePeerPullCreditTransactionState(peerInc);
peerInc.kycInfo = {
paytoHash: kycPending.h_payto,
requirementRow: kycPending.requirement_row,
};
peerInc.kycUrl = kycStatus.kyc_url;
peerInc.status = PeerPullPaymentCreditStatus.PendingMergeKycRequired;
const newTxState = computePeerPullCreditTransactionState(peerInc);
await tx.peerPullCredit.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 TaskRunResult.backoff();
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
}
/**
* Check fees and available exchanges for a peer push payment initiation.
*/
export async function checkPeerPullPaymentInitiation(
wex: WalletExecutionContext,
req: CheckPeerPullCreditRequest,
): Promise {
// FIXME: We don't support exchanges with purse fees yet.
// Select an exchange where we have money in the specified currency
// FIXME: How do we handle regional currency scopes here? Is it an additional input?
logger.trace("checking peer-pull-credit fees");
const currency = Amounts.currencyOf(req.amount);
let exchangeUrl;
if (req.exchangeBaseUrl) {
exchangeUrl = req.exchangeBaseUrl;
} else {
exchangeUrl = await getPreferredExchangeForCurrency(wex, currency);
}
if (!exchangeUrl) {
throw Error("no exchange found for initiating a peer pull payment");
}
logger.trace(`found ${exchangeUrl} as preferred exchange`);
const wi = await getExchangeWithdrawalInfo(
wex,
exchangeUrl,
Amounts.parseOrThrow(req.amount),
undefined,
);
logger.trace(`got withdrawal info`);
let numCoins = 0;
for (let i = 0; i < wi.selectedDenoms.selectedDenoms.length; i++) {
numCoins += wi.selectedDenoms.selectedDenoms[i].count;
}
return {
exchangeBaseUrl: exchangeUrl,
amountEffective: wi.withdrawalAmountEffective,
amountRaw: req.amount,
numCoins,
};
}
/**
* Find a preferred exchange based on when we withdrew last from this exchange.
*/
async function getPreferredExchangeForCurrency(
wex: WalletExecutionContext,
currency: string,
): Promise {
// Find an exchange with the matching currency.
// Prefer exchanges with the most recent withdrawal.
const url = await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges"] },
async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
let candidate = undefined;
for (const e of exchanges) {
if (e.detailsPointer?.currency !== currency) {
continue;
}
if (!candidate) {
candidate = e;
continue;
}
if (candidate.lastWithdrawal && !e.lastWithdrawal) {
continue;
}
const exchangeLastWithdrawal = timestampOptionalPreciseFromDb(
e.lastWithdrawal,
);
const candidateLastWithdrawal = timestampOptionalPreciseFromDb(
candidate.lastWithdrawal,
);
if (exchangeLastWithdrawal && candidateLastWithdrawal) {
if (
AbsoluteTime.cmp(
AbsoluteTime.fromPreciseTimestamp(exchangeLastWithdrawal),
AbsoluteTime.fromPreciseTimestamp(candidateLastWithdrawal),
) > 0
) {
candidate = e;
}
}
}
if (candidate) {
return candidate.baseUrl;
}
return undefined;
},
);
return url;
}
/**
* Initiate a peer pull payment.
*/
export async function initiatePeerPullPayment(
wex: WalletExecutionContext,
req: InitiatePeerPullCreditRequest,
): Promise {
const currency = Amounts.currencyOf(req.partialContractTerms.amount);
let maybeExchangeBaseUrl: string | undefined;
if (req.exchangeBaseUrl) {
maybeExchangeBaseUrl = req.exchangeBaseUrl;
} else {
maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(wex, currency);
}
if (!maybeExchangeBaseUrl) {
throw Error("no exchange found for initiating a peer pull payment");
}
const exchangeBaseUrl = maybeExchangeBaseUrl;
await fetchFreshExchange(wex, exchangeBaseUrl);
const mergeReserveInfo = await getMergeReserveInfo(wex, {
exchangeBaseUrl: exchangeBaseUrl,
});
const pursePair = await wex.cryptoApi.createEddsaKeypair({});
const mergePair = await wex.cryptoApi.createEddsaKeypair({});
const contractTerms = req.partialContractTerms;
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const contractKeyPair = await wex.cryptoApi.createEddsaKeypair({});
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const mergeReserveRowId = mergeReserveInfo.rowId;
checkDbInvariant(!!mergeReserveRowId, `merge reserve for ${exchangeBaseUrl} without rowid`);
const contractEncNonce = encodeCrock(getRandomBytes(24));
const wi = await getExchangeWithdrawalInfo(
wex,
exchangeBaseUrl,
Amounts.parseOrThrow(req.partialContractTerms.amount),
undefined,
);
const mergeTimestamp = TalerPreciseTimestamp.now();
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullCredit", "contractTerms"] },
async (tx) => {
const ppi: PeerPullCreditRecord = {
amount: req.partialContractTerms.amount,
contractTermsHash: hContractTerms,
exchangeBaseUrl: exchangeBaseUrl,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
mergePriv: mergePair.priv,
mergePub: mergePair.pub,
status: PeerPullPaymentCreditStatus.PendingCreatePurse,
mergeTimestamp: timestampPreciseToDb(mergeTimestamp),
contractEncNonce,
mergeReserveRowId: mergeReserveRowId,
contractPriv: contractKeyPair.priv,
contractPub: contractKeyPair.pub,
withdrawalGroupId,
estimatedAmountEffective: wi.withdrawalAmountEffective,
};
await tx.peerPullCredit.put(ppi);
const oldTxState: TransactionState = {
major: TransactionMajorState.None,
};
const newTxState = computePeerPullCreditTransactionState(ppi);
await tx.contractTerms.put({
contractTermsRaw: contractTerms,
h: hContractTerms,
});
return { oldTxState, newTxState };
},
);
const ctx = new PeerPullCreditTransactionContext(wex, pursePair.pub);
notifyTransition(wex, ctx.transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(ctx.taskId);
// The pending-incoming balance has changed.
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: ctx.transactionId,
});
return {
talerUri: stringifyTalerUri({
type: TalerUriAction.PayPull,
exchangeBaseUrl: exchangeBaseUrl,
contractPriv: contractKeyPair.priv,
}),
transactionId: ctx.transactionId,
};
}
export function computePeerPullCreditTransactionState(
pullCreditRecord: PeerPullCreditRecord,
): TransactionState {
switch (pullCreditRecord.status) {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.CreatePurse,
};
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.MergeKycRequired,
};
case PeerPullPaymentCreditStatus.PendingReady:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Ready,
};
case PeerPullPaymentCreditStatus.Done:
return {
major: TransactionMajorState.Done,
};
case PeerPullPaymentCreditStatus.PendingWithdrawing:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Withdraw,
};
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.CreatePurse,
};
case PeerPullPaymentCreditStatus.SuspendedReady:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Ready,
};
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Withdraw,
};
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.MergeKycRequired,
};
case PeerPullPaymentCreditStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.DeletePurse,
};
case PeerPullPaymentCreditStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
case PeerPullPaymentCreditStatus.Expired:
return {
major: TransactionMajorState.Expired,
};
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.DeletePurse,
};
}
}
export function computePeerPullCreditTransactionActions(
pullCreditRecord: PeerPullCreditRecord,
): TransactionAction[] {
switch (pullCreditRecord.status) {
case PeerPullPaymentCreditStatus.PendingCreatePurse:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentCreditStatus.PendingMergeKycRequired:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentCreditStatus.PendingReady:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentCreditStatus.Done:
return [TransactionAction.Delete];
case PeerPullPaymentCreditStatus.PendingWithdrawing:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentCreditStatus.SuspendedCreatePurse:
return [TransactionAction.Resume, TransactionAction.Abort];
case PeerPullPaymentCreditStatus.SuspendedReady:
return [TransactionAction.Abort, TransactionAction.Resume];
case PeerPullPaymentCreditStatus.SuspendedWithdrawing:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPullPaymentCreditStatus.SuspendedMergeKycRequired:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPullPaymentCreditStatus.Aborted:
return [TransactionAction.Delete];
case PeerPullPaymentCreditStatus.AbortingDeletePurse:
return [TransactionAction.Suspend, TransactionAction.Fail];
case PeerPullPaymentCreditStatus.Failed:
return [TransactionAction.Delete];
case PeerPullPaymentCreditStatus.Expired:
return [TransactionAction.Delete];
case PeerPullPaymentCreditStatus.SuspendedAbortingDeletePurse:
return [TransactionAction.Resume, TransactionAction.Fail];
}
}