/*
This file is part of GNU Taler
(C) 2022-2024 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
*/
/**
* @fileoverview
* Implementation of the peer-pull-debit transaction, i.e.
* paying for an invoice the wallet received from another wallet.
*/
/**
* Imports.
*/
import {
AcceptPeerPullPaymentResponse,
Amounts,
CoinRefreshRequest,
ConfirmPeerPullDebitRequest,
ContractTermsUtil,
ExchangePurseDeposits,
HttpStatusCode,
Logger,
NotificationType,
ObservabilityEventType,
PeerContractTerms,
PreparePeerPullDebitRequest,
PreparePeerPullDebitResponse,
RefreshReason,
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
TalerPreciseTimestamp,
TalerProtocolViolationError,
TransactionAction,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
assertUnreachable,
checkLogicInvariant,
codecForAny,
codecForExchangeGetContractResponse,
codecForPeerContractTerms,
decodeCrock,
eddsaGetPublic,
encodeCrock,
getRandomBytes,
j2s,
parsePayPullUri,
} from "@gnu-taler/taler-util";
import {
HttpResponse,
readSuccessResponseJsonOrThrow,
readTalerErrorResponse,
} from "@gnu-taler/taler-util/http";
import { PreviousPayCoins, selectPeerCoins } from "./coinSelection.js";
import {
PendingTaskType,
TaskIdStr,
TaskRunResult,
TaskRunResultType,
TransactionContext,
TransitionResultType,
constructTaskIdentifier,
spendCoins,
} from "./common.js";
import {
PeerPullDebitRecordStatus,
PeerPullPaymentIncomingRecord,
RefreshOperationStatus,
WalletStoresV1,
timestampPreciseToDb,
} from "./db.js";
import {
codecForExchangePurseStatus,
getTotalPeerPaymentCost,
queryCoinInfosForSelection,
} from "./pay-peer-common.js";
import { DbReadWriteTransaction, StoreNames } from "./query.js";
import { createRefreshGroup } from "./refresh.js";
import {
constructTransactionIdentifier,
notifyTransition,
parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext } from "./wallet.js";
const logger = new Logger("pay-peer-pull-debit.ts");
/**
* Common context for a peer-pull-debit transaction.
*/
export class PeerPullDebitTransactionContext implements TransactionContext {
wex: WalletExecutionContext;
readonly transactionId: TransactionIdStr;
readonly taskId: TaskIdStr;
peerPullDebitId: string;
constructor(wex: WalletExecutionContext, peerPullDebitId: string) {
this.wex = wex;
this.transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId,
});
this.taskId = constructTaskIdentifier({
tag: PendingTaskType.PeerPullDebit,
peerPullDebitId,
});
this.peerPullDebitId = peerPullDebitId;
}
async deleteTransaction(): Promise {
const transactionId = this.transactionId;
const ws = this.wex;
const peerPullDebitId = this.peerPullDebitId;
await ws.db.runReadWriteTx(
{ storeNames: ["peerPullDebit", "tombstones"] },
async (tx) => {
const debit = await tx.peerPullDebit.get(peerPullDebitId);
if (debit) {
await tx.peerPullDebit.delete(peerPullDebitId);
await tx.tombstones.put({ id: transactionId });
}
},
);
}
async suspendTransaction(): Promise {
const taskId = this.taskId;
const transactionId = this.transactionId;
const wex = this.wex;
const peerPullDebitId = this.peerPullDebitId;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullDebit"] },
async (tx) => {
const pullDebitRec = await tx.peerPullDebit.get(peerPullDebitId);
if (!pullDebitRec) {
logger.warn(`peer pull debit ${peerPullDebitId} not found`);
return;
}
let newStatus: PeerPullDebitRecordStatus | undefined = undefined;
switch (pullDebitRec.status) {
case PeerPullDebitRecordStatus.DialogProposed:
break;
case PeerPullDebitRecordStatus.Done:
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.peerPullDebit.put(pullDebitRec);
return {
oldTxState,
newTxState,
};
}
return undefined;
},
);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.stopShepherdTask(taskId);
}
async resumeTransaction(): Promise {
const ctx = this;
await ctx.transition(async (pi) => {
switch (pi.status) {
case PeerPullDebitRecordStatus.SuspendedDeposit:
pi.status = PeerPullDebitRecordStatus.PendingDeposit;
return TransitionResultType.Transition;
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
return TransitionResultType.Transition;
case PeerPullDebitRecordStatus.Aborted:
case PeerPullDebitRecordStatus.AbortingRefresh:
case PeerPullDebitRecordStatus.Failed:
case PeerPullDebitRecordStatus.DialogProposed:
case PeerPullDebitRecordStatus.Done:
case PeerPullDebitRecordStatus.PendingDeposit:
return TransitionResultType.Stay;
}
});
this.wex.taskScheduler.startShepherdTask(this.taskId);
}
async failTransaction(): Promise {
const ctx = this;
await ctx.transition(async (pi) => {
switch (pi.status) {
case PeerPullDebitRecordStatus.SuspendedDeposit:
case PeerPullDebitRecordStatus.PendingDeposit:
case PeerPullDebitRecordStatus.AbortingRefresh:
case PeerPullDebitRecordStatus.SuspendedAbortingRefresh:
// FIXME: Should we also abort the corresponding refresh session?!
pi.status = PeerPullDebitRecordStatus.Failed;
return TransitionResultType.Transition;
default:
return TransitionResultType.Stay;
}
});
this.wex.taskScheduler.stopShepherdTask(this.taskId);
}
async abortTransaction(): Promise {
const ctx = this;
await ctx.transitionExtra(
{
extraStores: [
"coinAvailability",
"denominations",
"refreshGroups",
"refreshSessions",
"coins",
"coinAvailability",
],
},
async (pi, tx) => {
switch (pi.status) {
case PeerPullDebitRecordStatus.SuspendedDeposit:
case PeerPullDebitRecordStatus.PendingDeposit:
break;
default:
return TransitionResultType.Stay;
}
const currency = Amounts.currencyOf(pi.totalCostEstimated);
const coinPubs: CoinRefreshRequest[] = [];
if (!pi.coinSel) {
throw Error("invalid db state");
}
for (let i = 0; i < pi.coinSel.coinPubs.length; i++) {
coinPubs.push({
amount: pi.coinSel.contributions[i],
coinPub: pi.coinSel.coinPubs[i],
});
}
const refresh = await createRefreshGroup(
ctx.wex,
tx,
currency,
coinPubs,
RefreshReason.AbortPeerPullDebit,
this.transactionId,
);
pi.status = PeerPullDebitRecordStatus.AbortingRefresh;
pi.abortRefreshGroupId = refresh.refreshGroupId;
return TransitionResultType.Transition;
},
);
}
async transition(
f: (rec: PeerPullPaymentIncomingRecord) => Promise,
): Promise {
return this.transitionExtra(
{
extraStores: [],
},
f,
);
}
async transitionExtra<
StoreNameArray extends Array> = [],
>(
opts: { extraStores: StoreNameArray },
f: (
rec: PeerPullPaymentIncomingRecord,
tx: DbReadWriteTransaction<
typeof WalletStoresV1,
["peerPullDebit", ...StoreNameArray]
>,
) => Promise,
): Promise {
const wex = this.wex;
const extraStores = opts.extraStores ?? [];
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullDebit", ...extraStores] },
async (tx) => {
const pi = await tx.peerPullDebit.get(this.peerPullDebitId);
if (!pi) {
throw Error("peer pull payment not found anymore");
}
const oldTxState = computePeerPullDebitTransactionState(pi);
const res = await f(pi, tx);
switch (res) {
case TransitionResultType.Transition: {
await tx.peerPullDebit.put(pi);
const newTxState = computePeerPullDebitTransactionState(pi);
return {
oldTxState,
newTxState,
};
}
default:
return undefined;
}
},
);
wex.taskScheduler.stopShepherdTask(this.taskId);
notifyTransition(wex, this.transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(this.taskId);
}
}
async function handlePurseCreationConflict(
ctx: PeerPullDebitTransactionContext,
peerPullInc: PeerPullPaymentIncomingRecord,
resp: HttpResponse,
): Promise {
const ws = ctx.wex;
const errResp = await readTalerErrorResponse(resp);
if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
await ctx.failTransaction();
return TaskRunResult.finished();
}
// FIXME: Properly parse!
const brokenCoinPub = (errResp as any).coin_pub;
logger.trace(`excluded broken coin pub=${brokenCoinPub}`);
if (!brokenCoinPub) {
// FIXME: Details!
throw new TalerProtocolViolationError();
}
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
const sel = peerPullInc.coinSel;
if (!sel) {
throw Error("invalid state (coin selection expected)");
}
const repair: PreviousPayCoins = [];
for (let i = 0; i < sel.coinPubs.length; i++) {
if (sel.coinPubs[i] != brokenCoinPub) {
repair.push({
coinPub: sel.coinPubs[i],
contribution: Amounts.parseOrThrow(sel.contributions[i]),
});
}
}
const coinSelRes = await selectPeerCoins(ws, {
instructedAmount,
repair,
});
switch (coinSelRes.type) {
case "failure":
// FIXME: Details!
throw Error(
"insufficient balance to re-select coins to repair double spending",
);
case "prospective":
throw Error(
"insufficient balance to re-select coins to repair double spending (blocked on refresh)",
);
case "success":
break;
default:
assertUnreachable(coinSelRes);
}
const totalAmount = await getTotalPeerPaymentCost(
ws,
coinSelRes.result.coins,
);
await ws.db.runReadWriteTx({ storeNames: ["peerPullDebit"] }, async (tx) => {
const myPpi = await tx.peerPullDebit.get(peerPullInc.peerPullDebitId);
if (!myPpi) {
return;
}
switch (myPpi.status) {
case PeerPullDebitRecordStatus.PendingDeposit:
case PeerPullDebitRecordStatus.SuspendedDeposit: {
const sel = coinSelRes.result;
myPpi.coinSel = {
coinPubs: sel.coins.map((x) => x.coinPub),
contributions: sel.coins.map((x) => x.contribution),
totalCost: Amounts.stringify(totalAmount),
};
break;
}
default:
return;
}
await tx.peerPullDebit.put(myPpi);
});
return TaskRunResult.backoff();
}
async function processPeerPullDebitPendingDeposit(
wex: WalletExecutionContext,
peerPullInc: PeerPullPaymentIncomingRecord,
): Promise {
const ctx = new PeerPullDebitTransactionContext(
wex,
peerPullInc.peerPullDebitId,
);
const pursePub = peerPullInc.pursePub;
const coinSel = peerPullInc.coinSel;
if (!coinSel) {
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
});
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
}
let coins: SelectedProspectiveCoin[] | undefined = undefined;
switch (coinSelRes.type) {
case "failure":
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
case "prospective":
throw Error("insufficient balance (locked behind refresh)");
case "success":
coins = coinSelRes.result.coins;
break;
default:
assertUnreachable(coinSelRes);
}
const peerPullDebitId = peerPullInc.peerPullDebitId;
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
// FIXME: Missing notification here!
const transitionDone = await wex.db.runReadWriteTx(
{
storeNames: [
"exchanges",
"coins",
"denominations",
"refreshGroups",
"refreshSessions",
"peerPullDebit",
"coinAvailability",
],
},
async (tx) => {
const pi = await tx.peerPullDebit.get(peerPullDebitId);
if (!pi) {
return false;
}
if (pi.status !== PeerPullDebitRecordStatus.PendingDeposit) {
return false;
}
if (pi.coinSel) {
return false;
}
await spendCoins(wex, tx, {
// allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
allocationId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId,
}),
coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
contributions: coinSelRes.result.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPull,
});
pi.coinSel = {
coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
contributions: coinSelRes.result.coins.map((x) => x.contribution),
totalCost: Amounts.stringify(totalAmount),
};
await tx.peerPullDebit.put(pi);
return true;
},
);
if (transitionDone) {
return TaskRunResult.progress();
} else {
return TaskRunResult.backoff();
}
}
const purseDepositUrl = new URL(
`purses/${pursePub}/deposit`,
peerPullInc.exchangeBaseUrl,
);
// FIXME: We could skip batches that we've already submitted.
const coins = await queryCoinInfosForSelection(wex, coinSel);
const maxBatchSize = 100;
for (let i = 0; i < coins.length; i += maxBatchSize) {
const batchSize = Math.min(maxBatchSize, coins.length - i);
wex.oc.observe({
type: ObservabilityEventType.Message,
contents: `Depositing batch at ${i}/${coins.length} of size ${batchSize}`,
});
const batchCoins = coins.slice(i, i + batchSize);
const depositSigsResp = await wex.cryptoApi.signPurseDeposits({
exchangeBaseUrl: peerPullInc.exchangeBaseUrl,
pursePub: peerPullInc.pursePub,
coins: batchCoins,
});
const depositPayload: ExchangePurseDeposits = {
deposits: depositSigsResp.deposits,
};
if (logger.shouldLogTrace()) {
logger.trace(`purse deposit payload: ${j2s(depositPayload)}`);
}
const httpResp = await wex.http.fetch(purseDepositUrl.href, {
method: "POST",
body: depositPayload,
cancellationToken: wex.cancellationToken,
});
switch (httpResp.status) {
case HttpStatusCode.Ok: {
const resp = await readSuccessResponseJsonOrThrow(
httpResp,
codecForAny(),
);
logger.trace(`purse deposit response: ${j2s(resp)}`);
continue;
}
case HttpStatusCode.Gone: {
await ctx.abortTransaction();
return TaskRunResult.backoff();
}
case HttpStatusCode.Conflict: {
return handlePurseCreationConflict(ctx, peerPullInc, httpResp);
}
default: {
const errResp = await readTalerErrorResponse(httpResp);
return {
type: TaskRunResultType.Error,
errorDetail: errResp,
};
}
}
}
// All batches succeeded, we can transition!
await ctx.transition(async (r) => {
if (r.status !== PeerPullDebitRecordStatus.PendingDeposit) {
return TransitionResultType.Stay;
}
r.status = PeerPullDebitRecordStatus.Done;
return TransitionResultType.Transition;
});
return TaskRunResult.finished();
}
async function processPeerPullDebitAbortingRefresh(
wex: WalletExecutionContext,
peerPullInc: PeerPullPaymentIncomingRecord,
): Promise {
const peerPullDebitId = peerPullInc.peerPullDebitId;
const abortRefreshGroupId = peerPullInc.abortRefreshGroupId;
checkLogicInvariant(!!abortRefreshGroupId);
const transactionId = constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId,
});
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["peerPullDebit", "refreshGroups"] },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: PeerPullDebitRecordStatus | undefined;
if (!refreshGroup) {
// Maybe it got manually deleted? Means that we should
// just go into failed.
logger.warn("no aborting refresh group found for deposit group");
newOpState = PeerPullDebitRecordStatus.Failed;
} else {
if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
newOpState = PeerPullDebitRecordStatus.Aborted;
} else if (
refreshGroup.operationStatus === RefreshOperationStatus.Failed
) {
newOpState = PeerPullDebitRecordStatus.Failed;
}
}
if (newOpState) {
const newDg = await tx.peerPullDebit.get(peerPullDebitId);
if (!newDg) {
return;
}
const oldTxState = computePeerPullDebitTransactionState(newDg);
newDg.status = newOpState;
const newTxState = computePeerPullDebitTransactionState(newDg);
await tx.peerPullDebit.put(newDg);
return { oldTxState, newTxState };
}
return undefined;
},
);
notifyTransition(wex, transactionId, transitionInfo);
// FIXME: Shouldn't this be finished in some cases?!
return TaskRunResult.backoff();
}
export async function processPeerPullDebit(
wex: WalletExecutionContext,
peerPullDebitId: string,
): Promise {
const peerPullInc = await wex.db.runReadOnlyTx(
{ storeNames: ["peerPullDebit"] },
async (tx) => {
return tx.peerPullDebit.get(peerPullDebitId);
},
);
if (!peerPullInc) {
throw Error("peer pull debit not found");
}
switch (peerPullInc.status) {
case PeerPullDebitRecordStatus.PendingDeposit:
return await processPeerPullDebitPendingDeposit(wex, peerPullInc);
case PeerPullDebitRecordStatus.AbortingRefresh:
return await processPeerPullDebitAbortingRefresh(wex, peerPullInc);
}
return TaskRunResult.finished();
}
export async function confirmPeerPullDebit(
wex: WalletExecutionContext,
req: ConfirmPeerPullDebitRequest,
): Promise {
let peerPullDebitId: string;
const parsedTx = parseTransactionIdentifier(req.transactionId);
if (!parsedTx || parsedTx.tag !== TransactionType.PeerPullDebit) {
throw Error("invalid peer-pull-debit transaction identifier");
}
peerPullDebitId = parsedTx.peerPullDebitId;
const peerPullInc = await wex.db.runReadOnlyTx(
{ storeNames: ["peerPullDebit"] },
async (tx) => {
return tx.peerPullDebit.get(peerPullDebitId);
},
);
if (!peerPullInc) {
throw Error(
`can't accept unknown incoming p2p pull payment (${req.transactionId})`,
);
}
const instructedAmount = Amounts.parseOrThrow(peerPullInc.amount);
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
});
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
}
let coins: SelectedProspectiveCoin[] | undefined = undefined;
switch (coinSelRes.type) {
case "failure":
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
case "prospective":
coins = coinSelRes.result.prospectiveCoins;
break;
case "success":
coins = coinSelRes.result.coins;
break;
default:
assertUnreachable(coinSelRes);
}
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
// FIXME: Missing notification here!
await wex.db.runReadWriteTx(
{
storeNames: [
"exchanges",
"coins",
"denominations",
"refreshGroups",
"refreshSessions",
"peerPullDebit",
"coinAvailability",
],
},
async (tx) => {
const pi = await tx.peerPullDebit.get(peerPullDebitId);
if (!pi) {
throw Error();
}
if (pi.status !== PeerPullDebitRecordStatus.DialogProposed) {
return;
}
if (coinSelRes.type == "success") {
await spendCoins(wex, tx, {
// allocationId: `txn:peer-pull-debit:${req.peerPullDebitId}`,
allocationId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId,
}),
coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
contributions: coinSelRes.result.coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
),
refreshReason: RefreshReason.PayPeerPull,
});
pi.coinSel = {
coinPubs: coinSelRes.result.coins.map((x) => x.coinPub),
contributions: coinSelRes.result.coins.map((x) => x.contribution),
totalCost: Amounts.stringify(totalAmount),
};
}
pi.status = PeerPullDebitRecordStatus.PendingDeposit;
await tx.peerPullDebit.put(pi);
},
);
const ctx = new PeerPullDebitTransactionContext(wex, peerPullDebitId);
const transactionId = ctx.transactionId;
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
transactionId,
};
}
/**
* Look up information about an incoming peer pull payment.
* Store the results in the wallet DB.
*/
export async function preparePeerPullDebit(
wex: WalletExecutionContext,
req: PreparePeerPullDebitRequest,
): Promise {
const uri = parsePayPullUri(req.talerUri);
if (!uri) {
throw Error("got invalid taler://pay-pull URI");
}
const existing = await wex.db.runReadOnlyTx(
{ storeNames: ["peerPullDebit", "contractTerms"] },
async (tx) => {
const peerPullDebitRecord =
await tx.peerPullDebit.indexes.byExchangeAndContractPriv.get([
uri.exchangeBaseUrl,
uri.contractPriv,
]);
if (!peerPullDebitRecord) {
return;
}
const contractTerms = await tx.contractTerms.get(
peerPullDebitRecord.contractTermsHash,
);
if (!contractTerms) {
return;
}
return { peerPullDebitRecord, contractTerms };
},
);
if (existing) {
return {
amount: existing.peerPullDebitRecord.amount,
amountRaw: existing.peerPullDebitRecord.amount,
amountEffective: existing.peerPullDebitRecord.totalCostEstimated,
contractTerms: existing.contractTerms.contractTermsRaw,
peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId: existing.peerPullDebitRecord.peerPullDebitId,
}),
};
}
const exchangeBaseUrl = uri.exchangeBaseUrl;
const contractPriv = uri.contractPriv;
const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv)));
const getContractUrl = new URL(`contracts/${contractPub}`, exchangeBaseUrl);
const contractHttpResp = await wex.http.fetch(getContractUrl.href);
const contractResp = await readSuccessResponseJsonOrThrow(
contractHttpResp,
codecForExchangeGetContractResponse(),
);
const pursePub = contractResp.purse_pub;
const dec = await wex.cryptoApi.decryptContractForDeposit({
ciphertext: contractResp.econtract,
contractPriv: contractPriv,
pursePub: pursePub,
});
const getPurseUrl = new URL(`purses/${pursePub}/merge`, exchangeBaseUrl);
const purseHttpResp = await wex.http.fetch(getPurseUrl.href);
const purseStatus = await readSuccessResponseJsonOrThrow(
purseHttpResp,
codecForExchangePurseStatus(),
);
const peerPullDebitId = 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");
}
const contractTermsHash = ContractTermsUtil.hashContractTerms(contractTerms);
// FIXME: Why don't we compute the totalCost here?!
const instructedAmount = Amounts.parseOrThrow(contractTerms.amount);
const coinSelRes = await selectPeerCoins(wex, {
instructedAmount,
});
if (logger.shouldLogTrace()) {
logger.trace(`selected p2p coins (pull): ${j2s(coinSelRes)}`);
}
let coins: SelectedProspectiveCoin[] | undefined = undefined;
switch (coinSelRes.type) {
case "failure":
throw TalerError.fromDetail(
TalerErrorCode.WALLET_PEER_PUSH_PAYMENT_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: coinSelRes.insufficientBalanceDetails,
},
);
case "prospective":
coins = coinSelRes.result.prospectiveCoins;
break;
case "success":
coins = coinSelRes.result.coins;
break;
default:
assertUnreachable(coinSelRes);
}
const totalAmount = await getTotalPeerPaymentCost(wex, coins);
await wex.db.runReadWriteTx(
{ storeNames: ["peerPullDebit", "contractTerms"] },
async (tx) => {
await tx.contractTerms.put({
h: contractTermsHash,
contractTermsRaw: contractTerms,
}),
await tx.peerPullDebit.add({
peerPullDebitId,
contractPriv: contractPriv,
exchangeBaseUrl: exchangeBaseUrl,
pursePub: pursePub,
timestampCreated: timestampPreciseToDb(TalerPreciseTimestamp.now()),
contractTermsHash,
amount: contractTerms.amount,
status: PeerPullDebitRecordStatus.DialogProposed,
totalCostEstimated: Amounts.stringify(totalAmount),
});
},
);
return {
amount: contractTerms.amount,
amountEffective: Amounts.stringify(totalAmount),
amountRaw: contractTerms.amount,
contractTerms: contractTerms,
peerPullDebitId,
transactionId: constructTransactionIdentifier({
tag: TransactionType.PeerPullDebit,
peerPullDebitId: peerPullDebitId,
}),
};
}
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.Done:
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.Done:
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];
}
}