wallet-core: restructure p2p impl

This commit is contained in:
Florian Dold 2023-06-05 11:45:16 +02:00
parent f3d4ff4e3a
commit fda5a0ed87
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
9 changed files with 3512 additions and 3271 deletions

View File

@ -0,0 +1,463 @@
/*
This file is part of GNU Taler
(C) 2022 GNUnet e.V.
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/>
*/
/**
* Imports.
*/
import {
AgeCommitmentProof,
AmountJson,
AmountString,
Amounts,
Codec,
CoinPublicKeyString,
CoinStatus,
Logger,
PayPeerInsufficientBalanceDetails,
TalerProtocolTimestamp,
UnblindedSignature,
buildCodecForObject,
codecForAmountString,
codecForTimestamp,
codecOptional,
strcmp,
} from "@gnu-taler/taler-util";
import { SpendCoinDetails } from "../crypto/cryptoImplementation.js";
import {
DenominationRecord,
PeerPushPaymentCoinSelection,
ReserveRecord,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { getTotalRefreshCost } from "./refresh.js";
const logger = new Logger("operations/peer-to-peer.ts");
interface SelectedPeerCoin {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined;
}
interface PeerCoinSelectionDetails {
exchangeBaseUrl: string;
/**
* Info of Coins that were selected.
*/
coins: SelectedPeerCoin[];
/**
* How much of the deposit fees is the customer paying?
*/
depositFees: AmountJson;
}
/**
* Information about a selected coin for peer to peer payments.
*/
interface CoinInfo {
/**
* Public key of the coin.
*/
coinPub: string;
coinPriv: string;
/**
* Deposit fee for the coin.
*/
feeDeposit: AmountJson;
value: AmountJson;
denomPubHash: string;
denomSig: UnblindedSignature;
maxAge: number;
ageCommitmentProof?: AgeCommitmentProof;
}
export type SelectPeerCoinsResult =
| { type: "success"; result: PeerCoinSelectionDetails }
| {
type: "failure";
insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
};
export async function queryCoinInfosForSelection(
ws: InternalWalletState,
csel: PeerPushPaymentCoinSelection,
): Promise<SpendCoinDetails[]> {
let infos: SpendCoinDetails[] = [];
await ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
for (let i = 0; i < csel.coinPubs.length; i++) {
const coin = await tx.coins.get(csel.coinPubs[i]);
if (!coin) {
throw Error("coin not found anymore");
}
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denom) {
throw Error("denom for coin not found anymore");
}
infos.push({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof,
contribution: csel.contributions[i],
});
}
});
return infos;
}
export interface PeerCoinSelectionRequest {
instructedAmount: AmountJson;
/**
* Instruct the coin selection to repair this coin
* selection instead of selecting completely new coins.
*/
repair?: {
exchangeBaseUrl: string;
coinPubs: CoinPublicKeyString[];
contribs: AmountJson[];
};
}
export async function selectPeerCoins(
ws: InternalWalletState,
req: PeerCoinSelectionRequest,
): Promise<SelectPeerCoinsResult> {
const instructedAmount = req.instructedAmount;
if (Amounts.isZero(instructedAmount)) {
// Other parts of the code assume that we have at least
// one coin to spend.
throw new Error("amount of zero not allowed");
}
return await ws.db
.mktx((x) => [
x.exchanges,
x.contractTerms,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.peerPushPaymentInitiations,
])
.runReadWrite(async (tx) => {
const exchanges = await tx.exchanges.iter().toArray();
const exchangeFeeGap: { [url: string]: AmountJson } = {};
const currency = Amounts.currencyOf(instructedAmount);
for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== currency) {
continue;
}
// FIXME: Can't we do this faster by using coinAvailability?
const coins = (
await tx.coins.indexes.byBaseUrl.getAll(exch.baseUrl)
).filter((x) => x.status === CoinStatus.Fresh);
const coinInfos: CoinInfo[] = [];
for (const coin of coins) {
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
if (!denom) {
throw Error("denom not found");
}
coinInfos.push({
coinPub: coin.coinPub,
feeDeposit: Amounts.parseOrThrow(denom.feeDeposit),
value: Amounts.parseOrThrow(denom.value),
denomPubHash: denom.denomPubHash,
coinPriv: coin.coinPriv,
denomSig: coin.denomSig,
maxAge: coin.maxAge,
ageCommitmentProof: coin.ageCommitmentProof,
});
}
if (coinInfos.length === 0) {
continue;
}
coinInfos.sort(
(o1, o2) =>
-Amounts.cmp(o1.value, o2.value) ||
strcmp(o1.denomPubHash, o2.denomPubHash),
);
let amountAcc = Amounts.zeroOfCurrency(currency);
let depositFeesAcc = Amounts.zeroOfCurrency(currency);
const resCoins: {
coinPub: string;
coinPriv: string;
contribution: AmountString;
denomPubHash: string;
denomSig: UnblindedSignature;
ageCommitmentProof: AgeCommitmentProof | undefined;
}[] = [];
let lastDepositFee = Amounts.zeroOfCurrency(currency);
if (req.repair) {
for (let i = 0; i < req.repair.coinPubs.length; i++) {
const contrib = req.repair.contribs[i];
const coin = await tx.coins.get(req.repair.coinPubs[i]);
if (!coin) {
throw Error("repair not possible, coin not found");
}
const denom = await ws.getDenomInfo(
ws,
tx,
coin.exchangeBaseUrl,
coin.denomPubHash,
);
checkDbInvariant(!!denom);
resCoins.push({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contribution: Amounts.stringify(contrib),
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof,
});
const depositFee = Amounts.parseOrThrow(denom.feeDeposit);
lastDepositFee = depositFee;
amountAcc = Amounts.add(
amountAcc,
Amounts.sub(contrib, depositFee).amount,
).amount;
depositFeesAcc = Amounts.add(depositFeesAcc, depositFee).amount;
}
}
for (const coin of coinInfos) {
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
break;
}
const gap = Amounts.add(
coin.feeDeposit,
Amounts.sub(instructedAmount, amountAcc).amount,
).amount;
const contrib = Amounts.min(gap, coin.value);
amountAcc = Amounts.add(
amountAcc,
Amounts.sub(contrib, coin.feeDeposit).amount,
).amount;
depositFeesAcc = Amounts.add(depositFeesAcc, coin.feeDeposit).amount;
resCoins.push({
coinPriv: coin.coinPriv,
coinPub: coin.coinPub,
contribution: Amounts.stringify(contrib),
denomPubHash: coin.denomPubHash,
denomSig: coin.denomSig,
ageCommitmentProof: coin.ageCommitmentProof,
});
lastDepositFee = coin.feeDeposit;
}
if (Amounts.cmp(amountAcc, instructedAmount) >= 0) {
const res: PeerCoinSelectionDetails = {
exchangeBaseUrl: exch.baseUrl,
coins: resCoins,
depositFees: depositFeesAcc,
};
return { type: "success", result: res };
}
const diff = Amounts.sub(instructedAmount, amountAcc).amount;
exchangeFeeGap[exch.baseUrl] = Amounts.add(lastDepositFee, diff).amount;
continue;
}
// We were unable to select coins.
// Now we need to produce error details.
const infoGeneral = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
currency,
});
const perExchange: PayPeerInsufficientBalanceDetails["perExchange"] = {};
let maxFeeGapEstimate = Amounts.zeroOfCurrency(currency);
for (const exch of exchanges) {
if (exch.detailsPointer?.currency !== currency) {
continue;
}
const infoExchange = await getPeerPaymentBalanceDetailsInTx(ws, tx, {
currency,
restrictExchangeTo: exch.baseUrl,
});
let gap =
exchangeFeeGap[exch.baseUrl] ?? Amounts.zeroOfCurrency(currency);
if (Amounts.cmp(infoExchange.balanceMaterial, instructedAmount) < 0) {
// Show fee gap only if we should've been able to pay with the material amount
gap = Amounts.zeroOfCurrency(currency);
}
perExchange[exch.baseUrl] = {
balanceAvailable: Amounts.stringify(infoExchange.balanceAvailable),
balanceMaterial: Amounts.stringify(infoExchange.balanceMaterial),
feeGapEstimate: Amounts.stringify(gap),
};
maxFeeGapEstimate = Amounts.max(maxFeeGapEstimate, gap);
}
const errDetails: PayPeerInsufficientBalanceDetails = {
amountRequested: Amounts.stringify(instructedAmount),
balanceAvailable: Amounts.stringify(infoGeneral.balanceAvailable),
balanceMaterial: Amounts.stringify(infoGeneral.balanceMaterial),
feeGapEstimate: Amounts.stringify(maxFeeGapEstimate),
perExchange,
};
return { type: "failure", insufficientBalanceDetails: errDetails };
});
}
export async function getTotalPeerPaymentCost(
ws: InternalWalletState,
pcs: SelectedPeerCoin[],
): Promise<AmountJson> {
return ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
const costs: AmountJson[] = [];
for (let i = 0; i < pcs.length; i++) {
const coin = await tx.coins.get(pcs[i].coinPub);
if (!coin) {
throw Error("can't calculate payment cost, coin not found");
}
const denom = await tx.denominations.get([
coin.exchangeBaseUrl,
coin.denomPubHash,
]);
if (!denom) {
throw Error(
"can't calculate payment cost, denomination for coin not found",
);
}
const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
.iter(coin.exchangeBaseUrl)
.filter((x) =>
Amounts.isSameCurrency(
DenominationRecord.getValue(x),
pcs[i].contribution,
),
);
const amountLeft = Amounts.sub(
DenominationRecord.getValue(denom),
pcs[i].contribution,
).amount;
const refreshCost = getTotalRefreshCost(
allDenoms,
DenominationRecord.toDenomInfo(denom),
amountLeft,
ws.config.testing.denomselAllowLate,
);
costs.push(Amounts.parseOrThrow(pcs[i].contribution));
costs.push(refreshCost);
}
const zero = Amounts.zeroOfAmount(pcs[0].contribution);
return Amounts.sum([zero, ...costs]).amount;
});
}
interface ExchangePurseStatus {
balance: AmountString;
deposit_timestamp?: TalerProtocolTimestamp;
merge_timestamp?: TalerProtocolTimestamp;
}
export const codecForExchangePurseStatus = (): Codec<ExchangePurseStatus> =>
buildCodecForObject<ExchangePurseStatus>()
.property("balance", codecForAmountString())
.property("deposit_timestamp", codecOptional(codecForTimestamp))
.property("merge_timestamp", codecOptional(codecForTimestamp))
.build("ExchangePurseStatus");
export function talerPaytoFromExchangeReserve(
exchangeBaseUrl: string,
reservePub: string,
): string {
const url = new URL(exchangeBaseUrl);
let proto: string;
if (url.protocol === "http:") {
proto = "taler-reserve-http";
} else if (url.protocol === "https:") {
proto = "taler-reserve";
} else {
throw Error(`unsupported exchange base URL protocol (${url.protocol})`);
}
let path = url.pathname;
if (!path.endsWith("/")) {
path = path + "/";
}
return `payto://${proto}/${url.host}${url.pathname}${reservePub}`;
}
export async function getMergeReserveInfo(
ws: InternalWalletState,
req: {
exchangeBaseUrl: string;
},
): Promise<ReserveRecord> {
// We have to eagerly create the key pair outside of the transaction,
// due to the async crypto API.
const newReservePair = await ws.cryptoApi.createEddsaKeypair({});
const mergeReserveRecord: ReserveRecord = await ws.db
.mktx((x) => [x.exchanges, x.reserves, x.withdrawalGroups])
.runReadWrite(async (tx) => {
const ex = await tx.exchanges.get(req.exchangeBaseUrl);
checkDbInvariant(!!ex);
if (ex.currentMergeReserveRowId != null) {
const reserve = await tx.reserves.get(ex.currentMergeReserveRowId);
checkDbInvariant(!!reserve);
return reserve;
}
const reserve: ReserveRecord = {
reservePriv: newReservePair.priv,
reservePub: newReservePair.pub,
};
const insertResp = await tx.reserves.put(reserve);
checkDbInvariant(typeof insertResp.key === "number");
reserve.rowId = insertResp.key;
ex.currentMergeReserveRowId = reserve.rowId;
await tx.exchanges.put(ex);
return reserve;
});
return mergeReserveRecord;
}

View File

@ -0,0 +1,910 @@
/*
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 {
AbsoluteTime,
Amounts,
CancellationToken,
CheckPeerPullCreditRequest,
CheckPeerPullCreditResponse,
ContractTermsUtil,
ExchangeReservePurseRequest,
HttpStatusCode,
InitiatePeerPullCreditRequest,
InitiatePeerPullCreditResponse,
Logger,
TalerPreciseTimestamp,
TransactionAction,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
WalletAccountMergeFlags,
codecForAny,
codecForWalletKycUuid,
constructPayPullUri,
encodeCrock,
getRandomBytes,
j2s,
} from "@gnu-taler/taler-util";
import {
readSuccessResponseJsonOrErrorCode,
readSuccessResponseJsonOrThrow,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import {
PeerPullPaymentInitiationRecord,
PeerPullPaymentInitiationStatus,
WithdrawalGroupStatus,
WithdrawalRecordType,
updateExchangeFromUrl,
} from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { PendingTaskType } from "../pending-types.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
import { checkDbInvariant } from "../util/invariants.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
constructTaskIdentifier,
} from "../util/retries.js";
import {
LongpollResult,
resetOperationTimeout,
runLongpollAsync,
runOperationWithErrorReporting,
} from "./common.js";
import {
codecForExchangePurseStatus,
getMergeReserveInfo,
talerPaytoFromExchangeReserve,
} from "./pay-peer-common.js";
import {
constructTransactionIdentifier,
notifyTransition,
stopLongpolling,
} from "./transactions.js";
import {
checkWithdrawalKycStatus,
getExchangeWithdrawalInfo,
internalCreateWithdrawalGroup,
processWithdrawalGroup,
} from "./withdraw.js";
const logger = new Logger("pay-peer-pull-credit.ts");
export async function queryPurseForPeerPullCredit(
ws: InternalWalletState,
pullIni: PeerPullPaymentInitiationRecord,
cancellationToken: CancellationToken,
): Promise<LongpollResult> {
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 ws.http.get(purseDepositUrl.href, {
timeout: { d_ms: 60000 },
cancellationToken,
});
logger.info(`purse status code: HTTP ${resp.status}`);
const result = await readSuccessResponseJsonOrErrorCode(
resp,
codecForExchangePurseStatus(),
);
if (result.isError) {
logger.info(`got purse status error, EC=${result.talerErrorResponse.code}`);
if (resp.status === 404) {
return { ready: false };
} else {
throwUnexpectedRequestError(resp, result.talerErrorResponse);
}
}
if (!result.response.deposit_timestamp) {
logger.info("purse not ready yet (no deposit)");
return { ready: false };
}
const reserve = await ws.db
.mktx((x) => [x.reserves])
.runReadOnly(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(ws, {
amount: Amounts.parseOrThrow(pullIni.amount),
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPullCredit,
contractTerms: pullIni.contractTerms,
contractPriv: pullIni.contractPriv,
},
forcedWithdrawalGroupId: pullIni.withdrawalGroupId,
exchangeBaseUrl: pullIni.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
reserveKeyPair: {
priv: reserve.reservePriv,
pub: reserve.reservePub,
},
});
await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const finPi = await tx.peerPullPaymentInitiations.get(pullIni.pursePub);
if (!finPi) {
logger.warn("peerPullPaymentInitiation not found anymore");
return;
}
if (finPi.status === PeerPullPaymentInitiationStatus.PendingReady) {
finPi.status = PeerPullPaymentInitiationStatus.DonePurseDeposited;
}
await tx.peerPullPaymentInitiations.put(finPi);
});
return {
ready: true,
};
}
export async function processPeerPullCredit(
ws: InternalWalletState,
pursePub: string,
): Promise<OperationAttemptResult> {
const pullIni = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadOnly(async (tx) => {
return tx.peerPullPaymentInitiations.get(pursePub);
});
if (!pullIni) {
throw Error("peer pull payment initiation not found in database");
}
const retryTag = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
// We're already running!
if (ws.activeLongpoll[retryTag]) {
logger.info("peer-pull-credit already in long-polling, returning!");
return {
type: OperationAttemptResultType.Longpoll,
};
}
logger.trace(`processing ${retryTag}, status=${pullIni.status}`);
switch (pullIni.status) {
case PeerPullPaymentInitiationStatus.DonePurseDeposited: {
// We implement this case so that the "retry" action on a peer-pull-credit transaction
// also retries the withdrawal task.
logger.warn(
"peer pull payment initiation is already finished, retrying withdrawal",
);
const withdrawalGroupId = pullIni.withdrawalGroupId;
if (withdrawalGroupId) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.Withdraw,
withdrawalGroupId,
});
stopLongpolling(ws, taskId);
await resetOperationTimeout(ws, taskId);
await runOperationWithErrorReporting(ws, taskId, () =>
processWithdrawalGroup(ws, withdrawalGroupId),
);
}
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
case PeerPullPaymentInitiationStatus.PendingReady:
runLongpollAsync(ws, retryTag, async (cancellationToken) =>
queryPurseForPeerPullCredit(ws, pullIni, cancellationToken),
);
logger.trace(
"returning early from processPeerPullCredit for long-polling in background",
);
return {
type: OperationAttemptResultType.Longpoll,
};
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired: {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pullIni.pursePub,
});
if (pullIni.kycInfo) {
await checkWithdrawalKycStatus(
ws,
pullIni.exchangeBaseUrl,
transactionId,
pullIni.kycInfo,
"individual",
);
}
break;
}
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
break;
default:
throw Error(`unknown PeerPullPaymentInitiationStatus ${pullIni.status}`);
}
const mergeReserve = await ws.db
.mktx((x) => [x.reserves])
.runReadOnly(async (tx) => {
return tx.reserves.get(pullIni.mergeReserveRowId);
});
if (!mergeReserve) {
throw Error("merge reserve for peer pull payment not found in database");
}
const purseFee = Amounts.stringify(Amounts.zeroOfAmount(pullIni.amount));
const reservePayto = talerPaytoFromExchangeReserve(
pullIni.exchangeBaseUrl,
mergeReserve.reservePub,
);
const econtractResp = await ws.cryptoApi.encryptContractForDeposit({
contractPriv: pullIni.contractPriv,
contractPub: pullIni.contractPub,
contractTerms: pullIni.contractTerms,
pursePriv: pullIni.pursePriv,
pursePub: pullIni.pursePub,
});
const purseExpiration = pullIni.contractTerms.purse_expiration;
const sigRes = await ws.cryptoApi.signReservePurseCreate({
contractTermsHash: pullIni.contractTermsHash,
flags: WalletAccountMergeFlags.CreateWithPurseFee,
mergePriv: pullIni.mergePriv,
mergeTimestamp: TalerPreciseTimestamp.round(pullIni.mergeTimestamp),
purseAmount: pullIni.contractTerms.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(pullIni.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.contractTerms.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 ws.http.postJson(
reservePurseMergeUrl.href,
reservePurseReqBody,
);
if (httpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
const respJson = await httpResp.json();
const kycPending = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(kycPending)}`);
await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const peerIni = await tx.peerPullPaymentInitiations.get(pursePub);
if (!peerIni) {
return;
}
peerIni.kycInfo = {
paytoHash: kycPending.h_payto,
requirementRow: kycPending.requirement_row,
};
peerIni.status =
PeerPullPaymentInitiationStatus.PendingMergeKycRequired;
await tx.peerPullPaymentInitiations.put(peerIni);
});
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
}
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
logger.info(`reserve merge response: ${j2s(resp)}`);
await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pi2 = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pi2) {
return;
}
pi2.status = PeerPullPaymentInitiationStatus.PendingReady;
await tx.peerPullPaymentInitiations.put(pi2);
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
/**
* Check fees and available exchanges for a peer push payment initiation.
*/
export async function checkPeerPullPaymentInitiation(
ws: InternalWalletState,
req: CheckPeerPullCreditRequest,
): Promise<CheckPeerPullCreditResponse> {
// 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(ws, 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(
ws,
exchangeUrl,
Amounts.parseOrThrow(req.amount),
undefined,
);
logger.trace(`got withdrawal info`);
return {
exchangeBaseUrl: exchangeUrl,
amountEffective: wi.withdrawalAmountEffective,
amountRaw: req.amount,
};
}
/**
* Find a preferred exchange based on when we withdrew last from this exchange.
*/
async function getPreferredExchangeForCurrency(
ws: InternalWalletState,
currency: string,
): Promise<string | undefined> {
// Find an exchange with the matching currency.
// Prefer exchanges with the most recent withdrawal.
const url = await ws.db
.mktx((x) => [x.exchanges])
.runReadOnly(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;
}
if (candidate.lastWithdrawal && e.lastWithdrawal) {
if (
AbsoluteTime.cmp(
AbsoluteTime.fromPreciseTimestamp(e.lastWithdrawal),
AbsoluteTime.fromPreciseTimestamp(candidate.lastWithdrawal),
) > 0
) {
candidate = e;
}
}
}
if (candidate) {
return candidate.baseUrl;
}
return undefined;
});
return url;
}
/**
* Initiate a peer pull payment.
*/
export async function initiatePeerPullPayment(
ws: InternalWalletState,
req: InitiatePeerPullCreditRequest,
): Promise<InitiatePeerPullCreditResponse> {
const currency = Amounts.currencyOf(req.partialContractTerms.amount);
let maybeExchangeBaseUrl: string | undefined;
if (req.exchangeBaseUrl) {
maybeExchangeBaseUrl = req.exchangeBaseUrl;
} else {
maybeExchangeBaseUrl = await getPreferredExchangeForCurrency(ws, currency);
}
if (!maybeExchangeBaseUrl) {
throw Error("no exchange found for initiating a peer pull payment");
}
const exchangeBaseUrl = maybeExchangeBaseUrl;
await updateExchangeFromUrl(ws, exchangeBaseUrl);
const mergeReserveInfo = await getMergeReserveInfo(ws, {
exchangeBaseUrl: exchangeBaseUrl,
});
const mergeTimestamp = TalerPreciseTimestamp.now();
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
const contractTerms = req.partialContractTerms;
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const mergeReserveRowId = mergeReserveInfo.rowId;
checkDbInvariant(!!mergeReserveRowId);
const wi = await getExchangeWithdrawalInfo(
ws,
exchangeBaseUrl,
Amounts.parseOrThrow(req.partialContractTerms.amount),
undefined,
);
await ws.db
.mktx((x) => [x.peerPullPaymentInitiations, x.contractTerms])
.runReadWrite(async (tx) => {
await tx.peerPullPaymentInitiations.put({
amount: req.partialContractTerms.amount,
contractTermsHash: hContractTerms,
exchangeBaseUrl: exchangeBaseUrl,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
mergePriv: mergePair.priv,
mergePub: mergePair.pub,
status: PeerPullPaymentInitiationStatus.PendingCreatePurse,
contractTerms: contractTerms,
mergeTimestamp,
mergeReserveRowId: mergeReserveRowId,
contractPriv: contractKeyPair.priv,
contractPub: contractKeyPair.pub,
withdrawalGroupId,
estimatedAmountEffective: wi.withdrawalAmountEffective,
});
await tx.contractTerms.put({
contractTermsRaw: contractTerms,
h: hContractTerms,
});
});
// FIXME: Should we somehow signal to the client
// whether purse creation has failed, or does the client/
// check this asynchronously from the transaction status?
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub: pursePair.pub,
});
await runOperationWithErrorReporting(ws, taskId, async () => {
return processPeerPullCredit(ws, pursePair.pub);
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub: pursePair.pub,
});
return {
talerUri: constructPayPullUri({
exchangeBaseUrl: exchangeBaseUrl,
contractPriv: contractKeyPair.priv,
}),
transactionId,
};
}
export async function suspendPeerPullCreditTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
newStatus = PeerPullPaymentInitiationStatus.SuspendedCreatePurse;
break;
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
newStatus = PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired;
break;
case PeerPullPaymentInitiationStatus.PendingWithdrawing:
newStatus = PeerPullPaymentInitiationStatus.SuspendedWithdrawing;
break;
case PeerPullPaymentInitiationStatus.PendingReady:
newStatus = PeerPullPaymentInitiationStatus.SuspendedReady;
break;
case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
newStatus =
PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse;
break;
case PeerPullPaymentInitiationStatus.DonePurseDeposited:
case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
case PeerPullPaymentInitiationStatus.SuspendedReady:
case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
case PeerPullPaymentInitiationStatus.Aborted:
case PeerPullPaymentInitiationStatus.Failed:
case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullPaymentInitiations.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function abortPeerPullCreditTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
break;
case PeerPullPaymentInitiationStatus.PendingWithdrawing:
throw Error("can't abort anymore");
case PeerPullPaymentInitiationStatus.PendingReady:
newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
break;
case PeerPullPaymentInitiationStatus.DonePurseDeposited:
case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
case PeerPullPaymentInitiationStatus.SuspendedReady:
case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
case PeerPullPaymentInitiationStatus.Aborted:
case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
case PeerPullPaymentInitiationStatus.Failed:
case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullPaymentInitiations.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function failPeerPullCreditTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
case PeerPullPaymentInitiationStatus.PendingWithdrawing:
case PeerPullPaymentInitiationStatus.PendingReady:
case PeerPullPaymentInitiationStatus.DonePurseDeposited:
case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
case PeerPullPaymentInitiationStatus.SuspendedReady:
case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
case PeerPullPaymentInitiationStatus.Aborted:
case PeerPullPaymentInitiationStatus.Failed:
break;
case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
newStatus = PeerPullPaymentInitiationStatus.Failed;
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullPaymentInitiations.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function resumePeerPullCreditTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullCredit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullCredit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentInitiations])
.runReadWrite(async (tx) => {
const pullCreditRec = await tx.peerPullPaymentInitiations.get(pursePub);
if (!pullCreditRec) {
logger.warn(`peer pull credit ${pursePub} not found`);
return;
}
let newStatus: PeerPullPaymentInitiationStatus | undefined = undefined;
switch (pullCreditRec.status) {
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
case PeerPullPaymentInitiationStatus.PendingWithdrawing:
case PeerPullPaymentInitiationStatus.PendingReady:
case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
case PeerPullPaymentInitiationStatus.DonePurseDeposited:
case PeerPullPaymentInitiationStatus.Failed:
case PeerPullPaymentInitiationStatus.Aborted:
break;
case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
newStatus = PeerPullPaymentInitiationStatus.PendingCreatePurse;
break;
case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
newStatus = PeerPullPaymentInitiationStatus.PendingMergeKycRequired;
break;
case PeerPullPaymentInitiationStatus.SuspendedReady:
newStatus = PeerPullPaymentInitiationStatus.PendingReady;
break;
case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
newStatus = PeerPullPaymentInitiationStatus.PendingWithdrawing;
break;
case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
newStatus = PeerPullPaymentInitiationStatus.AbortingDeletePurse;
break;
default:
assertUnreachable(pullCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullCreditTransactionState(pullCreditRec);
pullCreditRec.status = newStatus;
const newTxState = computePeerPullCreditTransactionState(pullCreditRec);
await tx.peerPullPaymentInitiations.put(pullCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
ws.workAvailable.trigger();
notifyTransition(ws, transactionId, transitionInfo);
}
export function computePeerPullCreditTransactionState(
pullCreditRecord: PeerPullPaymentInitiationRecord,
): TransactionState {
switch (pullCreditRecord.status) {
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.CreatePurse,
};
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.MergeKycRequired,
};
case PeerPullPaymentInitiationStatus.PendingReady:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Ready,
};
case PeerPullPaymentInitiationStatus.DonePurseDeposited:
return {
major: TransactionMajorState.Done,
};
case PeerPullPaymentInitiationStatus.PendingWithdrawing:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Withdraw,
};
case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.CreatePurse,
};
case PeerPullPaymentInitiationStatus.SuspendedReady:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Ready,
};
case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Withdraw,
};
case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.MergeKycRequired,
};
case PeerPullPaymentInitiationStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.DeletePurse,
};
case PeerPullPaymentInitiationStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.DeletePurse,
};
}
}
export function computePeerPullCreditTransactionActions(
pullCreditRecord: PeerPullPaymentInitiationRecord,
): TransactionAction[] {
switch (pullCreditRecord.status) {
case PeerPullPaymentInitiationStatus.PendingCreatePurse:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentInitiationStatus.PendingMergeKycRequired:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentInitiationStatus.PendingReady:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentInitiationStatus.DonePurseDeposited:
return [TransactionAction.Delete];
case PeerPullPaymentInitiationStatus.PendingWithdrawing:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullPaymentInitiationStatus.SuspendedCreatePurse:
return [TransactionAction.Resume, TransactionAction.Abort];
case PeerPullPaymentInitiationStatus.SuspendedReady:
return [TransactionAction.Abort, TransactionAction.Resume];
case PeerPullPaymentInitiationStatus.SuspendedWithdrawing:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPullPaymentInitiationStatus.SuspendedMergeKycRequired:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPullPaymentInitiationStatus.Aborted:
return [TransactionAction.Delete];
case PeerPullPaymentInitiationStatus.AbortingDeletePurse:
return [TransactionAction.Suspend, TransactionAction.Fail];
case PeerPullPaymentInitiationStatus.Failed:
return [TransactionAction.Delete];
case PeerPullPaymentInitiationStatus.SuspendedAbortingDeletePurse:
return [TransactionAction.Resume, TransactionAction.Fail];
}
}

View File

@ -0,0 +1,604 @@
/*
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 {
ConfirmPeerPullDebitRequest,
AcceptPeerPullPaymentResponse,
Amounts,
j2s,
TalerError,
TalerErrorCode,
TransactionType,
RefreshReason,
Logger,
PeerContractTerms,
PreparePeerPullDebitRequest,
PreparePeerPullDebitResponse,
TalerPreciseTimestamp,
codecForExchangeGetContractResponse,
codecForPeerContractTerms,
decodeCrock,
eddsaGetPublic,
encodeCrock,
getRandomBytes,
parsePayPullUri,
TransactionAction,
TransactionMajorState,
TransactionMinorState,
TransactionState,
} from "@gnu-taler/taler-util";
import {
InternalWalletState,
PeerPullDebitRecordStatus,
PeerPullPaymentIncomingRecord,
PendingTaskType,
} from "../index.js";
import { TaskIdentifiers, constructTaskIdentifier } from "../util/retries.js";
import { spendCoins, runOperationWithErrorReporting } from "./common.js";
import {
codecForExchangePurseStatus,
getTotalPeerPaymentCost,
selectPeerCoins,
} from "./pay-peer-common.js";
import { processPeerPullDebit } from "./pay-peer-push-credit.js";
import {
constructTransactionIdentifier,
notifyTransition,
stopLongpolling,
} from "./transactions.js";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import { assertUnreachable } from "../util/assertUnreachable.js";
const logger = new Logger("pay-peer-pull-debit.ts");
export async function confirmPeerPullDebit(
ws: InternalWalletState,
req: ConfirmPeerPullDebitRequest,
): Promise<AcceptPeerPullPaymentResponse> {
const peerPullInc = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadOnly(async (tx) => {
return tx.peerPullPaymentIncoming.get(req.peerPullPaymentIncomingId);
});
if (!peerPullInc) {
throw Error(
`can't accept unknown incoming p2p pull payment (${req.peerPullPaymentIncomingId})`,
);
}
const instructedAmount = Amounts.parseOrThrow(
peerPullInc.contractTerms.amount,
);
const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
if (coinSelRes.type !== "success") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const sel = coinSelRes.result;
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
const ppi = await ws.db
.mktx((x) => [
x.exchanges,
x.coins,
x.denominations,
x.refreshGroups,
x.peerPullPaymentIncoming,
x.coinAvailability,
])
.runReadWrite(async (tx) => {
await spendCoins(ws, tx, {
// allocationId: `txn:peer-pull-debit:${req.peerPullPaymentIncomingId}`,
allocationId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId: req.peerPullPaymentIncomingId,
}),
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPull,
});
const pi = await tx.peerPullPaymentIncoming.get(
req.peerPullPaymentIncomingId,
);
if (!pi) {
throw Error();
}
if (pi.status === PeerPullDebitRecordStatus.DialogProposed) {
pi.status = PeerPullDebitRecordStatus.PendingDeposit;
pi.coinSel = {
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) => x.contribution),
totalCost: Amounts.stringify(totalAmount),
};
}
await tx.peerPullPaymentIncoming.put(pi);
return pi;
});
await runOperationWithErrorReporting(
ws,
TaskIdentifiers.forPeerPullPaymentDebit(ppi),
async () => {
return processPeerPullDebit(ws, ppi.peerPullPaymentIncomingId);
},
);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId: req.peerPullPaymentIncomingId,
});
return {
transactionId,
};
}
/**
* Look up information about an incoming peer pull payment.
* Store the results in the wallet DB.
*/
export async function preparePeerPullDebit(
ws: InternalWalletState,
req: PreparePeerPullDebitRequest,
): Promise<PreparePeerPullDebitResponse> {
const uri = parsePayPullUri(req.talerUri);
if (!uri) {
throw Error("got invalid taler://pay-pull URI");
}
const existingPullIncomingRecord = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadOnly(async (tx) => {
return tx.peerPullPaymentIncoming.indexes.byExchangeAndContractPriv.get([
uri.exchangeBaseUrl,
uri.contractPriv,
]);
});
if (existingPullIncomingRecord) {
return {
amount: existingPullIncomingRecord.contractTerms.amount,
amountRaw: existingPullIncomingRecord.contractTerms.amount,
amountEffective: existingPullIncomingRecord.totalCostEstimated,
contractTerms: existingPullIncomingRecord.contractTerms,
peerPullPaymentIncomingId:
existingPullIncomingRecord.peerPullPaymentIncomingId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId:
existingPullIncomingRecord.peerPullPaymentIncomingId,
}),
};
}
const exchangeBaseUrl = uri.exchangeBaseUrl;
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
const contractHttpResp = await ws.http.get(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
codecForExchangeGetContractResponse(),
);
const pursePub = contractResp.purse_pub;
const dec = await ws.cryptoApi.decryptContractForDeposit({
ciphertext: contractResp.econtract,
contractPriv: contractPriv,
pursePub: pursePub,
});
const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const peerPullPaymentIncomingId = encodeCrock(getRandomBytes(32));
let contractTerms: PeerContractTerms;
if (dec.contractTerms) {
contractTerms = codecForPeerContractTerms().decode(dec.contractTerms);
// FIXME: Check that the purseStatus balance matches contract terms amount
} else {
// FIXME: In this case, where do we get the purse expiration from?!
// https://bugs.gnunet.org/view.php?id=7706
throw Error("pull payments without contract terms not supported yet");
}
// FIXME: Why don't we compute the totalCost here?!
const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
logger.info(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
if (coinSelRes.type !== "success") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
await tx.peerPullPaymentIncoming.add({
peerPullPaymentIncomingId,
contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
pursePub: pursePub,
timestampCreated: TalerPreciseTimestamp.now(),
contractTerms,
status: PeerPullDebitRecordStatus.DialogProposed,
totalCostEstimated: Amounts.stringify(totalAmount),
});
});
return {
amount: contractTerms.amount,
amountEffective: Amounts.stringify(totalAmount),
amountRaw: contractTerms.amount,
contractTerms: contractTerms,
peerPullPaymentIncomingId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId: peerPullPaymentIncomingId,
}),
};
}
export async function suspendPeerPullDebitTransaction(
ws: InternalWalletState,
peerPullPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullPaymentIncoming.get(
peerPullPaymentIncomingId,
);
if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
switch (pullDebitRec.status) {
case PeerPullDebitRecordStatus.DialogProposed:
break;
case PeerPullDebitRecordStatus.DonePaid:
break;
case PeerPullDebitRecordStatus.PendingDeposit:
newStatus = PeerPullDebitRecordStatus.SuspendedDeposit;
break;
case PeerPullDebitRecordStatus.SuspendedDeposit:
break;
case PeerPullDebitRecordStatus.Aborted:
break;
case PeerPullDebitRecordStatus.AbortingRefresh:
newStatus = PeerPullDebitRecordStatus.SuspendedAbortingRefresh;
break;
case PeerPullDebitRecordStatus.Failed:
break;
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
break;
default:
assertUnreachable(pullDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
pullDebitRec.status = newStatus;
const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
await tx.peerPullPaymentIncoming.put(pullDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function abortPeerPullDebitTransaction(
ws: InternalWalletState,
peerPullPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullPaymentIncoming.get(
peerPullPaymentIncomingId,
);
if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
switch (pullDebitRec.status) {
case PeerPullDebitRecordStatus.DialogProposed:
newStatus = PeerPullDebitRecordStatus.Aborted;
break;
case PeerPullDebitRecordStatus.DonePaid:
break;
case PeerPullDebitRecordStatus.PendingDeposit:
newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
break;
case PeerPullDebitRecordStatus.SuspendedDeposit:
break;
case PeerPullDebitRecordStatus.Aborted:
break;
case PeerPullDebitRecordStatus.AbortingRefresh:
break;
case PeerPullDebitRecordStatus.Failed:
break;
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
break;
default:
assertUnreachable(pullDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
pullDebitRec.status = newStatus;
const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
await tx.peerPullPaymentIncoming.put(pullDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function failPeerPullDebitTransaction(
ws: InternalWalletState,
peerPullPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullPaymentIncoming.get(
peerPullPaymentIncomingId,
);
if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
switch (pullDebitRec.status) {
case PeerPullDebitRecordStatus.DialogProposed:
newStatus = PeerPullDebitRecordStatus.Aborted;
break;
case PeerPullDebitRecordStatus.DonePaid:
break;
case PeerPullDebitRecordStatus.PendingDeposit:
break;
case PeerPullDebitRecordStatus.SuspendedDeposit:
break;
case PeerPullDebitRecordStatus.Aborted:
break;
case PeerPullDebitRecordStatus.Failed:
break;
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
case PeerPullDebitRecordStatus.AbortingRefresh:
// FIXME: abort underlying refresh!
newStatus = PeerPullDebitRecordStatus.Failed;
break;
default:
assertUnreachable(pullDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
pullDebitRec.status = newStatus;
const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
await tx.peerPullPaymentIncoming.put(pullDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function resumePeerPullDebitTransaction(
ws: InternalWalletState,
peerPullPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
const pullDebitRec = await tx.peerPullPaymentIncoming.get(
peerPullPaymentIncomingId,
);
if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
switch (pullDebitRec.status) {
case PeerPullDebitRecordStatus.DialogProposed:
case PeerPullDebitRecordStatus.DonePaid:
case PeerPullDebitRecordStatus.PendingDeposit:
break;
case PeerPullDebitRecordStatus.SuspendedDeposit:
newStatus = PeerPullDebitRecordStatus.PendingDeposit;
break;
case PeerPullDebitRecordStatus.Aborted:
break;
case PeerPullDebitRecordStatus.AbortingRefresh:
break;
case PeerPullDebitRecordStatus.Failed:
break;
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
newStatus = PeerPullDebitRecordStatus.AbortingRefresh;
break;
default:
assertUnreachable(pullDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPullDebitTransactionState(pullDebitRec);
pullDebitRec.status = newStatus;
const newTxState = computePeerPullDebitTransactionState(pullDebitRec);
await tx.peerPullPaymentIncoming.put(pullDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
ws.workAvailable.trigger();
notifyTransition(ws, transactionId, transitionInfo);
}
export function computePeerPullDebitTransactionState(
pullDebitRecord: PeerPullPaymentIncomingRecord,
): TransactionState {
switch (pullDebitRecord.status) {
case PeerPullDebitRecordStatus.DialogProposed:
return {
major: TransactionMajorState.Dialog,
minor: TransactionMinorState.Proposed,
};
case PeerPullDebitRecordStatus.PendingDeposit:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Deposit,
};
case PeerPullDebitRecordStatus.DonePaid:
return {
major: TransactionMajorState.Done,
};
case PeerPullDebitRecordStatus.SuspendedDeposit:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Deposit,
};
case PeerPullDebitRecordStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case PeerPullDebitRecordStatus.AbortingRefresh:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.Refresh,
};
case PeerPullDebitRecordStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
return {
major: TransactionMajorState.SuspendedAborting,
minor: TransactionMinorState.Refresh,
};
}
}
export function computePeerPullDebitTransactionActions(
pullDebitRecord: PeerPullPaymentIncomingRecord,
): TransactionAction[] {
switch (pullDebitRecord.status) {
case PeerPullDebitRecordStatus.DialogProposed:
return [];
case PeerPullDebitRecordStatus.PendingDeposit:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPullDebitRecordStatus.DonePaid:
return [TransactionAction.Delete];
case PeerPullDebitRecordStatus.SuspendedDeposit:
return [TransactionAction.Resume, TransactionAction.Abort];
case PeerPullDebitRecordStatus.Aborted:
return [TransactionAction.Delete];
case PeerPullDebitRecordStatus.AbortingRefresh:
return [TransactionAction.Fail, TransactionAction.Suspend];
case PeerPullDebitRecordStatus.Failed:
return [TransactionAction.Delete];
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
return [TransactionAction.Resume, TransactionAction.Fail];
}
}

View File

@ -0,0 +1,770 @@
/*
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 {
PreparePeerPushCredit,
PreparePeerPushCreditResponse,
parsePayPushUri,
codecForPeerContractTerms,
TransactionType,
encodeCrock,
eddsaGetPublic,
decodeCrock,
codecForExchangeGetContractResponse,
getRandomBytes,
ContractTermsUtil,
Amounts,
TalerPreciseTimestamp,
AcceptPeerPushPaymentResponse,
ConfirmPeerPushCreditRequest,
ExchangePurseMergeRequest,
HttpStatusCode,
PeerContractTerms,
TalerProtocolTimestamp,
WalletAccountMergeFlags,
codecForAny,
codecForWalletKycUuid,
j2s,
Logger,
ExchangePurseDeposits,
TransactionAction,
TransactionMajorState,
TransactionMinorState,
TransactionState,
} from "@gnu-taler/taler-util";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
InternalWalletState,
PeerPullDebitRecordStatus,
PeerPushPaymentIncomingRecord,
PeerPushPaymentIncomingStatus,
PendingTaskType,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../index.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
codecForExchangePurseStatus,
getMergeReserveInfo,
queryCoinInfosForSelection,
talerPaytoFromExchangeReserve,
} from "./pay-peer-common.js";
import { constructTransactionIdentifier, notifyTransition, stopLongpolling } from "./transactions.js";
import {
checkWithdrawalKycStatus,
getExchangeWithdrawalInfo,
internalCreateWithdrawalGroup,
} from "./withdraw.js";
import { checkDbInvariant } from "../util/invariants.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
constructTaskIdentifier,
} from "../util/retries.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
const logger = new Logger("pay-peer-push-credit.ts");
export async function preparePeerPushCredit(
ws: InternalWalletState,
req: PreparePeerPushCredit,
): Promise<PreparePeerPushCreditResponse> {
const uri = parsePayPushUri(req.talerUri);
if (!uri) {
throw Error("got invalid taler://pay-push URI");
}
const existing = await ws.db
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadOnly(async (tx) => {
const existingPushInc =
await tx.peerPushPaymentIncoming.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,
peerPushPaymentIncomingId:
existing.existingPushInc.peerPushPaymentIncomingId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId:
existing.existingPushInc.peerPushPaymentIncomingId,
}),
};
}
const exchangeBaseUrl = uri.exchangeBaseUrl;
await updateExchangeFromUrl(ws, exchangeBaseUrl);
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
const contractHttpResp = await ws.http.get(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
codecForExchangeGetContractResponse(),
);
const pursePub = contractResp.purse_pub;
const dec = await ws.cryptoApi.decryptContractForMerge({
ciphertext: contractResp.econtract,
contractPriv: contractPriv,
pursePub: pursePub,
});
const getPurseUrl = new URL(`purses/${pursePub}/deposit`, exchangeBaseUrl);
const purseHttpResp = await ws.http.get(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const peerPushPaymentIncomingId = encodeCrock(getRandomBytes(32));
const contractTermsHash = ContractTermsUtil.hashContractTerms(
dec.contractTerms,
);
const withdrawalGroupId = encodeCrock(getRandomBytes(32));
const wi = await getExchangeWithdrawalInfo(
ws,
exchangeBaseUrl,
Amounts.parseOrThrow(purseStatus.balance),
undefined,
);
await ws.db
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
await tx.peerPushPaymentIncoming.add({
peerPushPaymentIncomingId,
contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
mergePriv: dec.mergePriv,
pursePub: pursePub,
timestamp: TalerPreciseTimestamp.now(),
contractTermsHash,
status: PeerPushPaymentIncomingStatus.DialogProposed,
withdrawalGroupId,
currency: Amounts.currencyOf(purseStatus.balance),
estimatedAmountEffective: Amounts.stringify(
wi.withdrawalAmountEffective,
),
});
await tx.contractTerms.put({
h: contractTermsHash,
contractTermsRaw: dec.contractTerms,
});
});
return {
amount: purseStatus.balance,
amountEffective: wi.withdrawalAmountEffective,
amountRaw: purseStatus.balance,
contractTerms: dec.contractTerms,
peerPushPaymentIncomingId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId,
}),
};
}
export async function processPeerPushCredit(
ws: InternalWalletState,
peerPushPaymentIncomingId: string,
): Promise<OperationAttemptResult> {
let peerInc: PeerPushPaymentIncomingRecord | undefined;
let contractTerms: PeerContractTerms | undefined;
await ws.db
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
peerInc = await tx.peerPushPaymentIncoming.get(peerPushPaymentIncomingId);
if (!peerInc) {
return;
}
const ctRec = await tx.contractTerms.get(peerInc.contractTermsHash);
if (ctRec) {
contractTerms = ctRec.contractTermsRaw;
}
await tx.peerPushPaymentIncoming.put(peerInc);
});
if (!peerInc) {
throw Error(
`can't accept unknown incoming p2p push payment (${peerPushPaymentIncomingId})`,
);
}
checkDbInvariant(!!contractTerms);
const amount = Amounts.parseOrThrow(contractTerms.amount);
if (
peerInc.status === PeerPushPaymentIncomingStatus.PendingMergeKycRequired &&
peerInc.kycInfo
) {
const txId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId: peerInc.peerPushPaymentIncomingId,
});
await checkWithdrawalKycStatus(
ws,
peerInc.exchangeBaseUrl,
txId,
peerInc.kycInfo,
"individual",
);
}
const mergeReserveInfo = await getMergeReserveInfo(ws, {
exchangeBaseUrl: peerInc.exchangeBaseUrl,
});
const mergeTimestamp = TalerProtocolTimestamp.now();
const reservePayto = talerPaytoFromExchangeReserve(
peerInc.exchangeBaseUrl,
mergeReserveInfo.reservePub,
);
const sigRes = await ws.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 ws.http.postJson(mergePurseUrl.href, mergeReq);
if (mergeHttpResp.status === HttpStatusCode.UnavailableForLegalReasons) {
const respJson = await mergeHttpResp.json();
const kycPending = codecForWalletKycUuid().decode(respJson);
logger.info(`kyc uuid response: ${j2s(kycPending)}`);
await ws.db
.mktx((x) => [x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
const peerInc = await tx.peerPushPaymentIncoming.get(
peerPushPaymentIncomingId,
);
if (!peerInc) {
return;
}
peerInc.kycInfo = {
paytoHash: kycPending.h_payto,
requirementRow: kycPending.requirement_row,
};
peerInc.status = PeerPushPaymentIncomingStatus.PendingMergeKycRequired;
await tx.peerPushPaymentIncoming.put(peerInc);
});
return {
type: OperationAttemptResultType.Pending,
result: undefined,
};
}
logger.trace(`merge request: ${j2s(mergeReq)}`);
const res = await readSuccessResponseJsonOrThrow(
mergeHttpResp,
codecForAny(),
);
logger.trace(`merge response: ${j2s(res)}`);
await internalCreateWithdrawalGroup(ws, {
amount,
wgInfo: {
withdrawalType: WithdrawalRecordType.PeerPushCredit,
contractTerms,
},
forcedWithdrawalGroupId: peerInc.withdrawalGroupId,
exchangeBaseUrl: peerInc.exchangeBaseUrl,
reserveStatus: WithdrawalGroupStatus.PendingQueryingStatus,
reserveKeyPair: {
priv: mergeReserveInfo.reservePriv,
pub: mergeReserveInfo.reservePub,
},
});
await ws.db
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
const peerInc = await tx.peerPushPaymentIncoming.get(
peerPushPaymentIncomingId,
);
if (!peerInc) {
return;
}
if (
peerInc.status === PeerPushPaymentIncomingStatus.PendingMerge ||
peerInc.status === PeerPushPaymentIncomingStatus.PendingMergeKycRequired
) {
peerInc.status = PeerPushPaymentIncomingStatus.Done;
}
await tx.peerPushPaymentIncoming.put(peerInc);
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
export async function confirmPeerPushCredit(
ws: InternalWalletState,
req: ConfirmPeerPushCreditRequest,
): Promise<AcceptPeerPushPaymentResponse> {
let peerInc: PeerPushPaymentIncomingRecord | undefined;
await ws.db
.mktx((x) => [x.contractTerms, x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
peerInc = await tx.peerPushPaymentIncoming.get(
req.peerPushPaymentIncomingId,
);
if (!peerInc) {
return;
}
if (peerInc.status === PeerPushPaymentIncomingStatus.DialogProposed) {
peerInc.status = PeerPushPaymentIncomingStatus.PendingMerge;
}
await tx.peerPushPaymentIncoming.put(peerInc);
});
if (!peerInc) {
throw Error(
`can't accept unknown incoming p2p push payment (${req.peerPushPaymentIncomingId})`,
);
}
ws.workAvailable.trigger();
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId: req.peerPushPaymentIncomingId,
});
return {
transactionId,
};
}
export async function processPeerPullDebit(
ws: InternalWalletState,
peerPullPaymentIncomingId: string,
): Promise<OperationAttemptResult> {
const peerPullInc = await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadOnly(async (tx) => {
return tx.peerPullPaymentIncoming.get(peerPullPaymentIncomingId);
});
if (!peerPullInc) {
throw Error("peer pull debit not found");
}
if (peerPullInc.status === PeerPullDebitRecordStatus.PendingDeposit) {
const pursePub = peerPullInc.pursePub;
const coinSel = peerPullInc.coinSel;
if (!coinSel) {
throw Error("invalid state, no coins selected");
}
const coins = await queryCoinInfosForSelection(ws, coinSel);
const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
pursePub: peerPullInc.pursePub,
coins,
});
const purseDepositUrl = new URL(
`purses/${pursePub}/deposit`,
peerPullInc.exchangeBaseUrl,
);
const depositPayload: ExchangePurseDeposits = {
deposits: depositSigsResp.deposits,
};
if (logger.shouldLogTrace()) {
logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
}
const httpResp = await ws.http.postJson(
purseDepositUrl.href,
depositPayload,
);
const resp = await readSuccessResponseJsonOrThrow(httpResp, codecForAny());
logger.trace(`purse deposit response: ${j2s(resp)}`);
}
await ws.db
.mktx((x) => [x.peerPullPaymentIncoming])
.runReadWrite(async (tx) => {
const pi = await tx.peerPullPaymentIncoming.get(
peerPullPaymentIncomingId,
);
if (!pi) {
throw Error("peer pull payment not found anymore");
}
if (pi.status === PeerPullDebitRecordStatus.PendingDeposit) {
pi.status = PeerPullDebitRecordStatus.DonePaid;
}
await tx.peerPullPaymentIncoming.put(pi);
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
export async function suspendPeerPushCreditTransaction(
ws: InternalWalletState,
peerPushPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushCredit,
peerPushPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
const pushCreditRec = await tx.peerPushPaymentIncoming.get(
peerPushPaymentIncomingId,
);
if (!pushCreditRec) {
logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
switch (pushCreditRec.status) {
case PeerPushPaymentIncomingStatus.DialogProposed:
case PeerPushPaymentIncomingStatus.Done:
case PeerPushPaymentIncomingStatus.SuspendedMerge:
case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
break;
case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
newStatus = PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired;
break;
case PeerPushPaymentIncomingStatus.PendingMerge:
newStatus = PeerPushPaymentIncomingStatus.SuspendedMerge;
break;
case PeerPushPaymentIncomingStatus.PendingWithdrawing:
// FIXME: Suspend internal withdrawal transaction!
newStatus = PeerPushPaymentIncomingStatus.SuspendedWithdrawing;
break;
case PeerPushPaymentIncomingStatus.Aborted:
break;
case PeerPushPaymentIncomingStatus.Failed:
break;
default:
assertUnreachable(pushCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
pushCreditRec.status = newStatus;
const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
await tx.peerPushPaymentIncoming.put(pushCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function abortPeerPushCreditTransaction(
ws: InternalWalletState,
peerPushPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushCredit,
peerPushPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
const pushCreditRec = await tx.peerPushPaymentIncoming.get(
peerPushPaymentIncomingId,
);
if (!pushCreditRec) {
logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
switch (pushCreditRec.status) {
case PeerPushPaymentIncomingStatus.DialogProposed:
newStatus = PeerPushPaymentIncomingStatus.Aborted;
break;
case PeerPushPaymentIncomingStatus.Done:
break;
case PeerPushPaymentIncomingStatus.SuspendedMerge:
case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
newStatus = PeerPushPaymentIncomingStatus.Aborted;
break;
case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
newStatus = PeerPushPaymentIncomingStatus.Aborted;
break;
case PeerPushPaymentIncomingStatus.PendingMerge:
newStatus = PeerPushPaymentIncomingStatus.Aborted;
break;
case PeerPushPaymentIncomingStatus.PendingWithdrawing:
newStatus = PeerPushPaymentIncomingStatus.Aborted;
break;
case PeerPushPaymentIncomingStatus.Aborted:
break;
case PeerPushPaymentIncomingStatus.Failed:
break;
default:
assertUnreachable(pushCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
pushCreditRec.status = newStatus;
const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
await tx.peerPushPaymentIncoming.put(pushCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function failPeerPushCreditTransaction(
ws: InternalWalletState,
peerPushPaymentIncomingId: string,
) {
// We don't have any "aborting" states!
throw Error("can't run cancel-aborting on peer-push-credit transaction");
}
export async function resumePeerPushCreditTransaction(
ws: InternalWalletState,
peerPushPaymentIncomingId: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushCredit,
peerPushPaymentIncomingId,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushCredit,
peerPushPaymentIncomingId,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentIncoming])
.runReadWrite(async (tx) => {
const pushCreditRec = await tx.peerPushPaymentIncoming.get(
peerPushPaymentIncomingId,
);
if (!pushCreditRec) {
logger.warn(`peer push credit ${peerPushPaymentIncomingId} not found`);
return;
}
let newStatus: PeerPushPaymentIncomingStatus | undefined = undefined;
switch (pushCreditRec.status) {
case PeerPushPaymentIncomingStatus.DialogProposed:
case PeerPushPaymentIncomingStatus.Done:
case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
case PeerPushPaymentIncomingStatus.PendingMerge:
case PeerPushPaymentIncomingStatus.PendingWithdrawing:
case PeerPushPaymentIncomingStatus.SuspendedMerge:
newStatus = PeerPushPaymentIncomingStatus.PendingMerge;
break;
case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
newStatus = PeerPushPaymentIncomingStatus.PendingMergeKycRequired;
break;
case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
// FIXME: resume underlying "internal-withdrawal" transaction.
newStatus = PeerPushPaymentIncomingStatus.PendingWithdrawing;
break;
case PeerPushPaymentIncomingStatus.Aborted:
break;
case PeerPushPaymentIncomingStatus.Failed:
break;
default:
assertUnreachable(pushCreditRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushCreditTransactionState(pushCreditRec);
pushCreditRec.status = newStatus;
const newTxState = computePeerPushCreditTransactionState(pushCreditRec);
await tx.peerPushPaymentIncoming.put(pushCreditRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
ws.workAvailable.trigger();
notifyTransition(ws, transactionId, transitionInfo);
}
export function computePeerPushCreditTransactionState(
pushCreditRecord: PeerPushPaymentIncomingRecord,
): TransactionState {
switch (pushCreditRecord.status) {
case PeerPushPaymentIncomingStatus.DialogProposed:
return {
major: TransactionMajorState.Dialog,
minor: TransactionMinorState.Proposed,
};
case PeerPushPaymentIncomingStatus.PendingMerge:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Merge,
};
case PeerPushPaymentIncomingStatus.Done:
return {
major: TransactionMajorState.Done,
};
case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.KycRequired,
};
case PeerPushPaymentIncomingStatus.PendingWithdrawing:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Withdraw,
};
case PeerPushPaymentIncomingStatus.SuspendedMerge:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Merge,
};
case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.MergeKycRequired,
};
case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Withdraw,
};
case PeerPushPaymentIncomingStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case PeerPushPaymentIncomingStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
default:
assertUnreachable(pushCreditRecord.status);
}
}
export function computePeerPushCreditTransactionActions(
pushCreditRecord: PeerPushPaymentIncomingRecord,
): TransactionAction[] {
switch (pushCreditRecord.status) {
case PeerPushPaymentIncomingStatus.DialogProposed:
return [];
case PeerPushPaymentIncomingStatus.PendingMerge:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPushPaymentIncomingStatus.Done:
return [TransactionAction.Delete];
case PeerPushPaymentIncomingStatus.PendingMergeKycRequired:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPushPaymentIncomingStatus.PendingWithdrawing:
return [TransactionAction.Suspend, TransactionAction.Fail];
case PeerPushPaymentIncomingStatus.SuspendedMerge:
return [TransactionAction.Resume, TransactionAction.Abort];
case PeerPushPaymentIncomingStatus.SuspendedMergeKycRequired:
return [TransactionAction.Resume, TransactionAction.Abort];
case PeerPushPaymentIncomingStatus.SuspendedWithdrawing:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPushPaymentIncomingStatus.Aborted:
return [TransactionAction.Delete];
case PeerPushPaymentIncomingStatus.Failed:
return [TransactionAction.Delete];
default:
assertUnreachable(pushCreditRecord.status);
}
}

View File

@ -0,0 +1,742 @@
/*
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 {
Amounts,
CheckPeerPushDebitRequest,
CheckPeerPushDebitResponse,
ContractTermsUtil,
HttpStatusCode,
InitiatePeerPushDebitRequest,
InitiatePeerPushDebitResponse,
Logger,
RefreshReason,
TalerError,
TalerErrorCode,
TalerPreciseTimestamp,
TransactionAction,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
constructPayPushUri,
j2s,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import {
selectPeerCoins,
getTotalPeerPaymentCost,
codecForExchangePurseStatus,
queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { readSuccessResponseJsonOrThrow } from "@gnu-taler/taler-util/http";
import {
PeerPushPaymentInitiationRecord,
PeerPushPaymentInitiationStatus,
} from "../index.js";
import { PendingTaskType } from "../pending-types.js";
import {
OperationAttemptResult,
OperationAttemptResultType,
constructTaskIdentifier,
} from "../util/retries.js";
import {
runLongpollAsync,
spendCoins,
runOperationWithErrorReporting,
} from "./common.js";
import {
constructTransactionIdentifier,
notifyTransition,
stopLongpolling,
} from "./transactions.js";
import { assertUnreachable } from "../util/assertUnreachable.js";
const logger = new Logger("pay-peer-push-debit.ts");
export async function checkPeerPushDebit(
ws: InternalWalletState,
req: CheckPeerPushDebitRequest,
): Promise<CheckPeerPushDebitResponse> {
const instructedAmount = Amounts.parseOrThrow(req.amount);
const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
if (coinSelRes.type === "failure") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
return {
amountEffective: Amounts.stringify(totalAmount),
amountRaw: req.amount,
};
}
async function processPeerPushDebitCreateReserve(
ws: InternalWalletState,
peerPushInitiation: PeerPushPaymentInitiationRecord,
): Promise<OperationAttemptResult> {
const pursePub = peerPushInitiation.pursePub;
const purseExpiration = peerPushInitiation.purseExpiration;
const hContractTerms = peerPushInitiation.contractTermsHash;
const purseSigResp = await ws.cryptoApi.signPurseCreation({
hContractTerms,
mergePub: peerPushInitiation.mergePub,
minAge: 0,
purseAmount: peerPushInitiation.amount,
purseExpiration,
pursePriv: peerPushInitiation.pursePriv,
});
const coins = await queryCoinInfosForSelection(
ws,
peerPushInitiation.coinSel,
);
const depositSigsResp = await ws.cryptoApi.signPurseDeposits({
exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
pursePub: peerPushInitiation.pursePub,
coins,
});
const econtractResp = await ws.cryptoApi.encryptContractForMerge({
contractTerms: peerPushInitiation.contractTerms,
mergePriv: peerPushInitiation.mergePriv,
pursePriv: peerPushInitiation.pursePriv,
pursePub: peerPushInitiation.pursePub,
contractPriv: peerPushInitiation.contractPriv,
contractPub: peerPushInitiation.contractPub,
});
const createPurseUrl = new URL(
`purses/${peerPushInitiation.pursePub}/create`,
peerPushInitiation.exchangeBaseUrl,
);
const httpResp = await ws.http.fetch(createPurseUrl.href, {
method: "POST",
body: {
amount: peerPushInitiation.amount,
merge_pub: peerPushInitiation.mergePub,
purse_sig: purseSigResp.sig,
h_contract_terms: hContractTerms,
purse_expiration: purseExpiration,
deposits: depositSigsResp.deposits,
min_age: 0,
econtract: econtractResp.econtract,
},
});
const resp = await httpResp.json();
logger.info(`resp: ${j2s(resp)}`);
if (httpResp.status !== HttpStatusCode.Ok) {
throw Error("got error response from exchange");
}
await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const ppi = await tx.peerPushPaymentInitiations.get(pursePub);
if (!ppi) {
return;
}
ppi.status = PeerPushPaymentInitiationStatus.Done;
await tx.peerPushPaymentInitiations.put(ppi);
});
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
async function transitionPeerPushDebitFromReadyToDone(
ws: InternalWalletState,
pursePub: string,
): Promise<void> {
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub,
});
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const ppiRec = await tx.peerPushPaymentInitiations.get(pursePub);
if (!ppiRec) {
return undefined;
}
if (ppiRec.status !== PeerPushPaymentInitiationStatus.PendingReady) {
return undefined;
}
const oldTxState = computePeerPushDebitTransactionState(ppiRec);
ppiRec.status = PeerPushPaymentInitiationStatus.Done;
const newTxState = computePeerPushDebitTransactionState(ppiRec);
return {
oldTxState,
newTxState,
};
});
notifyTransition(ws, transactionId, transitionInfo);
}
/**
* Process the "pending(ready)" state of a peer-push-debit transaction.
*/
async function processPeerPushDebitReady(
ws: InternalWalletState,
peerPushInitiation: PeerPushPaymentInitiationRecord,
): Promise<OperationAttemptResult> {
const pursePub = peerPushInitiation.pursePub;
const retryTag = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub,
});
runLongpollAsync(ws, retryTag, async (ct) => {
const mergeUrl = new URL(`purses/${pursePub}/merge`);
mergeUrl.searchParams.set("timeout_ms", "30000");
const resp = await ws.http.fetch(mergeUrl.href, {
// timeout: getReserveRequestTimeout(withdrawalGroup),
cancellationToken: ct,
});
if (resp.status === HttpStatusCode.Ok) {
const purseStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForExchangePurseStatus(),
);
if (purseStatus.deposit_timestamp) {
await transitionPeerPushDebitFromReadyToDone(
ws,
peerPushInitiation.pursePub,
);
return {
ready: true,
};
}
} else if (resp.status === HttpStatusCode.Gone) {
// FIXME: transition the reserve into the expired state
}
return {
ready: false,
};
});
logger.trace(
"returning early from peer-push-debit for long-polling in background",
);
return {
type: OperationAttemptResultType.Longpoll,
};
}
export async function processPeerPushDebit(
ws: InternalWalletState,
pursePub: string,
): Promise<OperationAttemptResult> {
const peerPushInitiation = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadOnly(async (tx) => {
return tx.peerPushPaymentInitiations.get(pursePub);
});
if (!peerPushInitiation) {
throw Error("peer push payment not found");
}
const retryTag = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub,
});
// We're already running!
if (ws.activeLongpoll[retryTag]) {
logger.info("peer-push-debit task already in long-polling, returning!");
return {
type: OperationAttemptResultType.Longpoll,
};
}
switch (peerPushInitiation.status) {
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
return processPeerPushDebitCreateReserve(ws, peerPushInitiation);
case PeerPushPaymentInitiationStatus.PendingReady:
return processPeerPushDebitReady(ws, peerPushInitiation);
}
return {
type: OperationAttemptResultType.Finished,
result: undefined,
};
}
/**
* Initiate sending a peer-to-peer push payment.
*/
export async function initiatePeerPushDebit(
ws: InternalWalletState,
req: InitiatePeerPushDebitRequest,
): Promise<InitiatePeerPushDebitResponse> {
const instructedAmount = Amounts.parseOrThrow(
req.partialContractTerms.amount,
);
const purseExpiration = req.partialContractTerms.purse_expiration;
const contractTerms = req.partialContractTerms;
const pursePair = await ws.cryptoApi.createEddsaKeypair({});
const mergePair = await ws.cryptoApi.createEddsaKeypair({});
const hContractTerms = ContractTermsUtil.hashContractTerms(contractTerms);
const contractKeyPair = await ws.cryptoApi.createEddsaKeypair({});
const coinSelRes = await selectPeerCoins(ws, { instructedAmount });
if (coinSelRes.type !== "success") {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
}
const sel = coinSelRes.result;
logger.info(`selected p2p coins (push): ${j2s(coinSelRes)}`);
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
await ws.db
.mktx((x) => [
x.exchanges,
x.contractTerms,
x.coins,
x.coinAvailability,
x.denominations,
x.refreshGroups,
x.peerPushPaymentInitiations,
])
.runReadWrite(async (tx) => {
// FIXME: Instead of directly doing a spendCoin here,
// we might want to mark the coins as used and spend them
// after we've been able to create the purse.
await spendCoins(ws, tx, {
// allocationId: `txn:peer-push-debit:${pursePair.pub}`,
allocationId: constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub: pursePair.pub,
}),
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPush,
});
await tx.peerPushPaymentInitiations.add({
amount: Amounts.stringify(instructedAmount),
contractPriv: contractKeyPair.priv,
contractPub: contractKeyPair.pub,
contractTermsHash: hContractTerms,
exchangeBaseUrl: sel.exchangeBaseUrl,
mergePriv: mergePair.priv,
mergePub: mergePair.pub,
purseExpiration: purseExpiration,
pursePriv: pursePair.priv,
pursePub: pursePair.pub,
timestampCreated: TalerPreciseTimestamp.now(),
status: PeerPushPaymentInitiationStatus.PendingCreatePurse,
contractTerms: contractTerms,
coinSel: {
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) => x.contribution),
},
totalCost: Amounts.stringify(totalAmount),
});
await tx.contractTerms.put({
h: hContractTerms,
contractTermsRaw: contractTerms,
});
});
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub: pursePair.pub,
});
await runOperationWithErrorReporting(ws, taskId, async () => {
return await processPeerPushDebit(ws, pursePair.pub);
});
return {
contractPriv: contractKeyPair.priv,
mergePriv: mergePair.priv,
pursePub: pursePair.pub,
exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
talerUri: constructPayPushUri({
exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
contractPriv: contractKeyPair.priv,
}),
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub: pursePair.pub,
}),
};
}
export function computePeerPushDebitTransactionActions(
ppiRecord: PeerPushPaymentInitiationRecord,
): TransactionAction[] {
switch (ppiRecord.status) {
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPushPaymentInitiationStatus.PendingReady:
return [TransactionAction.Abort, TransactionAction.Suspend];
case PeerPushPaymentInitiationStatus.Aborted:
return [TransactionAction.Delete];
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
return [TransactionAction.Suspend, TransactionAction.Fail];
case PeerPushPaymentInitiationStatus.AbortingRefresh:
return [TransactionAction.Suspend, TransactionAction.Fail];
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
return [TransactionAction.Resume, TransactionAction.Fail];
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
return [TransactionAction.Resume, TransactionAction.Abort];
case PeerPushPaymentInitiationStatus.SuspendedReady:
return [TransactionAction.Suspend, TransactionAction.Abort];
case PeerPushPaymentInitiationStatus.Done:
return [TransactionAction.Delete];
case PeerPushPaymentInitiationStatus.Failed:
return [TransactionAction.Delete];
}
}
export async function abortPeerPushDebitTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
if (!pushDebitRec) {
logger.warn(`peer push debit ${pursePub} not found`);
return;
}
let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
switch (pushDebitRec.status) {
case PeerPushPaymentInitiationStatus.PendingReady:
case PeerPushPaymentInitiationStatus.SuspendedReady:
newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
break;
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
// Network request might already be in-flight!
newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
break;
case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
case PeerPushPaymentInitiationStatus.AbortingRefresh:
case PeerPushPaymentInitiationStatus.Done:
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
case PeerPushPaymentInitiationStatus.Aborted:
// Do nothing
break;
case PeerPushPaymentInitiationStatus.Failed:
break;
default:
assertUnreachable(pushDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
pushDebitRec.status = newStatus;
const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
await tx.peerPushPaymentInitiations.put(pushDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function failPeerPushDebitTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
if (!pushDebitRec) {
logger.warn(`peer push debit ${pursePub} not found`);
return;
}
let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
switch (pushDebitRec.status) {
case PeerPushPaymentInitiationStatus.AbortingRefresh:
case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
// FIXME: We also need to abort the refresh group!
newStatus = PeerPushPaymentInitiationStatus.Aborted;
break;
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
newStatus = PeerPushPaymentInitiationStatus.Aborted;
break;
case PeerPushPaymentInitiationStatus.PendingReady:
case PeerPushPaymentInitiationStatus.SuspendedReady:
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
case PeerPushPaymentInitiationStatus.Done:
case PeerPushPaymentInitiationStatus.Aborted:
case PeerPushPaymentInitiationStatus.Failed:
// Do nothing
break;
default:
assertUnreachable(pushDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
pushDebitRec.status = newStatus;
const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
await tx.peerPushPaymentInitiations.put(pushDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function suspendPeerPushDebitTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
if (!pushDebitRec) {
logger.warn(`peer push debit ${pursePub} not found`);
return;
}
let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
switch (pushDebitRec.status) {
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
newStatus = PeerPushPaymentInitiationStatus.SuspendedCreatePurse;
break;
case PeerPushPaymentInitiationStatus.AbortingRefresh:
newStatus = PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh;
break;
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
newStatus =
PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse;
break;
case PeerPushPaymentInitiationStatus.PendingReady:
newStatus = PeerPushPaymentInitiationStatus.SuspendedReady;
break;
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
case PeerPushPaymentInitiationStatus.SuspendedReady:
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
case PeerPushPaymentInitiationStatus.Done:
case PeerPushPaymentInitiationStatus.Aborted:
case PeerPushPaymentInitiationStatus.Failed:
// Do nothing
break;
default:
assertUnreachable(pushDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
pushDebitRec.status = newStatus;
const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
await tx.peerPushPaymentInitiations.put(pushDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
notifyTransition(ws, transactionId, transitionInfo);
}
export async function resumePeerPushDebitTransaction(
ws: InternalWalletState,
pursePub: string,
) {
const taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPushDebit,
pursePub,
});
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPushDebit,
pursePub,
});
stopLongpolling(ws, taskId);
const transitionInfo = await ws.db
.mktx((x) => [x.peerPushPaymentInitiations])
.runReadWrite(async (tx) => {
const pushDebitRec = await tx.peerPushPaymentInitiations.get(pursePub);
if (!pushDebitRec) {
logger.warn(`peer push debit ${pursePub} not found`);
return;
}
let newStatus: PeerPushPaymentInitiationStatus | undefined = undefined;
switch (pushDebitRec.status) {
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
newStatus = PeerPushPaymentInitiationStatus.AbortingDeletePurse;
break;
case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
newStatus = PeerPushPaymentInitiationStatus.AbortingRefresh;
break;
case PeerPushPaymentInitiationStatus.SuspendedReady:
newStatus = PeerPushPaymentInitiationStatus.PendingReady;
break;
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
newStatus = PeerPushPaymentInitiationStatus.PendingCreatePurse;
break;
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
case PeerPushPaymentInitiationStatus.AbortingRefresh:
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
case PeerPushPaymentInitiationStatus.PendingReady:
case PeerPushPaymentInitiationStatus.Done:
case PeerPushPaymentInitiationStatus.Aborted:
case PeerPushPaymentInitiationStatus.Failed:
// Do nothing
break;
default:
assertUnreachable(pushDebitRec.status);
}
if (newStatus != null) {
const oldTxState = computePeerPushDebitTransactionState(pushDebitRec);
pushDebitRec.status = newStatus;
const newTxState = computePeerPushDebitTransactionState(pushDebitRec);
await tx.peerPushPaymentInitiations.put(pushDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
});
ws.workAvailable.trigger();
notifyTransition(ws, transactionId, transitionInfo);
}
export function computePeerPushDebitTransactionState(
ppiRecord: PeerPushPaymentInitiationRecord,
): TransactionState {
switch (ppiRecord.status) {
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.CreatePurse,
};
case PeerPushPaymentInitiationStatus.PendingReady:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Ready,
};
case PeerPushPaymentInitiationStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.DeletePurse,
};
case PeerPushPaymentInitiationStatus.AbortingRefresh:
return {
major: TransactionMajorState.Aborting,
minor: TransactionMinorState.Refresh,
};
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
return {
major: TransactionMajorState.SuspendedAborting,
minor: TransactionMinorState.DeletePurse,
};
case PeerPushPaymentInitiationStatus.SuspendedAbortingRefresh:
return {
major: TransactionMajorState.SuspendedAborting,
minor: TransactionMinorState.Refresh,
};
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.CreatePurse,
};
case PeerPushPaymentInitiationStatus.SuspendedReady:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Ready,
};
case PeerPushPaymentInitiationStatus.Done:
return {
major: TransactionMajorState.Done,
};
case PeerPushPaymentInitiationStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -50,14 +50,10 @@ import { getBalances } from "./balance.js";
import { checkLogicInvariant } from "../util/invariants.js"; import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js"; import { acceptWithdrawalFromUri } from "./withdraw.js";
import { updateExchangeFromUrl } from "./exchanges.js"; import { updateExchangeFromUrl } from "./exchanges.js";
import { import { initiatePeerPullPayment } from "./pay-peer-pull-credit.js";
confirmPeerPullDebit, import { preparePeerPullDebit, confirmPeerPullDebit } from "./pay-peer-pull-debit.js";
confirmPeerPushCredit, import { preparePeerPushCredit, confirmPeerPushCredit } from "./pay-peer-push-credit.js";
initiatePeerPullPayment, import { initiatePeerPushDebit } from "./pay-peer-push-debit.js";
initiatePeerPushDebit,
preparePeerPullDebit,
preparePeerPushCredit,
} from "./pay-peer.js";
const logger = new Logger("operations/testing.ts"); const logger = new Logger("operations/testing.ts");

View File

@ -92,32 +92,6 @@ import {
suspendPayMerchant, suspendPayMerchant,
computePayMerchantTransactionActions, computePayMerchantTransactionActions,
} from "./pay-merchant.js"; } from "./pay-merchant.js";
import {
abortPeerPullCreditTransaction,
abortPeerPullDebitTransaction,
abortPeerPushCreditTransaction,
abortPeerPushDebitTransaction,
failPeerPullCreditTransaction,
failPeerPullDebitTransaction,
failPeerPushCreditTransaction,
failPeerPushDebitTransaction,
computePeerPullCreditTransactionState,
computePeerPullDebitTransactionState,
computePeerPushCreditTransactionState,
computePeerPushDebitTransactionState,
resumePeerPullCreditTransaction,
resumePeerPullDebitTransaction,
resumePeerPushCreditTransaction,
resumePeerPushDebitTransaction,
suspendPeerPullCreditTransaction,
suspendPeerPullDebitTransaction,
suspendPeerPushCreditTransaction,
suspendPeerPushDebitTransaction,
computePeerPushDebitTransactionActions,
computePeerPullDebitTransactionActions,
computePeerPullCreditTransactionActions,
computePeerPushCreditTransactionActions,
} from "./pay-peer.js";
import { import {
abortRefreshGroup, abortRefreshGroup,
failRefreshGroup, failRefreshGroup,
@ -143,6 +117,10 @@ import {
suspendWithdrawalTransaction, suspendWithdrawalTransaction,
computeWithdrawalTransactionActions, computeWithdrawalTransactionActions,
} from "./withdraw.js"; } from "./withdraw.js";
import { computePeerPullCreditTransactionState, computePeerPullCreditTransactionActions, suspendPeerPullCreditTransaction, failPeerPullCreditTransaction, resumePeerPullCreditTransaction, abortPeerPullCreditTransaction } from "./pay-peer-pull-credit.js";
import { computePeerPullDebitTransactionState, computePeerPullDebitTransactionActions, suspendPeerPullDebitTransaction, failPeerPullDebitTransaction, resumePeerPullDebitTransaction, abortPeerPullDebitTransaction } from "./pay-peer-pull-debit.js";
import { computePeerPushCreditTransactionState, computePeerPushCreditTransactionActions, suspendPeerPushCreditTransaction, failPeerPushCreditTransaction, resumePeerPushCreditTransaction, abortPeerPushCreditTransaction } from "./pay-peer-push-credit.js";
import { computePeerPushDebitTransactionState, computePeerPushDebitTransactionActions, suspendPeerPushDebitTransaction, failPeerPushDebitTransaction, resumePeerPushDebitTransaction, abortPeerPushDebitTransaction } from "./pay-peer-push-debit.js";
const logger = new Logger("taler-wallet-core:transactions.ts"); const logger = new Logger("taler-wallet-core:transactions.ts");

View File

@ -63,8 +63,6 @@ import {
codecForAddKnownBankAccounts, codecForAddKnownBankAccounts,
codecForAny, codecForAny,
codecForApplyDevExperiment, codecForApplyDevExperiment,
codecForApplyRefundFromPurchaseIdRequest,
codecForApplyRefundRequest,
codecForCancelAbortingTransactionRequest, codecForCancelAbortingTransactionRequest,
codecForCheckPeerPullPaymentRequest, codecForCheckPeerPullPaymentRequest,
codecForCheckPeerPushDebitRequest, codecForCheckPeerPushDebitRequest,
@ -196,22 +194,29 @@ import {
getContractTermsDetails, getContractTermsDetails,
preparePayForUri, preparePayForUri,
processPurchase, processPurchase,
startQueryRefund,
startRefundQueryForUri, startRefundQueryForUri,
} from "./operations/pay-merchant.js"; } from "./operations/pay-merchant.js";
import { import {
checkPeerPullPaymentInitiation, checkPeerPullPaymentInitiation,
checkPeerPushDebit,
confirmPeerPullDebit,
confirmPeerPushCredit,
initiatePeerPullPayment, initiatePeerPullPayment,
initiatePeerPushDebit,
preparePeerPullDebit,
preparePeerPushCredit,
processPeerPullCredit, processPeerPullCredit,
} from "./operations/pay-peer-pull-credit.js";
import {
confirmPeerPullDebit,
preparePeerPullDebit,
} from "./operations/pay-peer-pull-debit.js";
import {
confirmPeerPushCredit,
preparePeerPushCredit,
processPeerPullDebit, processPeerPullDebit,
processPeerPushCredit, processPeerPushCredit,
} from "./operations/pay-peer-push-credit.js";
import {
checkPeerPushDebit,
initiatePeerPushDebit,
processPeerPushDebit, processPeerPushDebit,
} from "./operations/pay-peer.js"; } from "./operations/pay-peer-push-debit.js";
import { getPendingOperations } from "./operations/pending.js"; import { getPendingOperations } from "./operations/pending.js";
import { import {
createRecoupGroup, createRecoupGroup,
@ -232,8 +237,8 @@ import {
import { acceptTip, prepareTip, processTip } from "./operations/tip.js"; import { acceptTip, prepareTip, processTip } from "./operations/tip.js";
import { import {
abortTransaction, abortTransaction,
failTransaction,
deleteTransaction, deleteTransaction,
failTransaction,
getTransactionById, getTransactionById,
getTransactions, getTransactions,
parseTransactionIdentifier, parseTransactionIdentifier,
@ -280,7 +285,6 @@ import {
WalletCoreApiClient, WalletCoreApiClient,
WalletCoreResponseType, WalletCoreResponseType,
} from "./wallet-api-types.js"; } from "./wallet-api-types.js";
import { startQueryRefund } from "./operations/pay-merchant.js";
const logger = new Logger("wallet.ts"); const logger = new Logger("wallet.ts");