1039 lines
34 KiB
TypeScript
1039 lines
34 KiB
TypeScript
/*
|
|
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,
|
|
CoinRefreshRequest,
|
|
ContractTermsUtil,
|
|
HttpStatusCode,
|
|
InitiatePeerPushDebitRequest,
|
|
InitiatePeerPushDebitResponse,
|
|
Logger,
|
|
RefreshReason,
|
|
TalerError,
|
|
TalerErrorCode,
|
|
TalerPreciseTimestamp,
|
|
TalerProtocolViolationError,
|
|
TalerUriAction,
|
|
TransactionAction,
|
|
TransactionMajorState,
|
|
TransactionMinorState,
|
|
TransactionState,
|
|
TransactionType,
|
|
decodeCrock,
|
|
encodeCrock,
|
|
getRandomBytes,
|
|
hash,
|
|
j2s,
|
|
stringifyTalerUri,
|
|
} from "@gnu-taler/taler-util";
|
|
import { InternalWalletState } from "../internal-wallet-state.js";
|
|
import {
|
|
selectPeerCoins,
|
|
getTotalPeerPaymentCost,
|
|
codecForExchangePurseStatus,
|
|
queryCoinInfosForSelection,
|
|
PeerCoinRepair,
|
|
} from "./pay-peer-common.js";
|
|
import {
|
|
HttpResponse,
|
|
readSuccessResponseJsonOrThrow,
|
|
readTalerErrorResponse,
|
|
} from "@gnu-taler/taler-util/http";
|
|
import {
|
|
PeerPushPaymentInitiationRecord,
|
|
PeerPushPaymentInitiationStatus,
|
|
RefreshOperationStatus,
|
|
createRefreshGroup,
|
|
} from "../index.js";
|
|
import { PendingTaskType } from "../pending-types.js";
|
|
import {
|
|
OperationAttemptResult,
|
|
OperationAttemptResultType,
|
|
constructTaskIdentifier,
|
|
} from "../util/retries.js";
|
|
import { runLongpollAsync, spendCoins } from "./common.js";
|
|
import {
|
|
constructTransactionIdentifier,
|
|
notifyTransition,
|
|
stopLongpolling,
|
|
} from "./transactions.js";
|
|
import { assertUnreachable } from "../util/assertUnreachable.js";
|
|
import { checkLogicInvariant } from "../util/invariants.js";
|
|
import { EncryptContractRequest } from "../crypto/cryptoTypes.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 handlePurseCreationConflict(
|
|
ws: InternalWalletState,
|
|
peerPushInitiation: PeerPushPaymentInitiationRecord,
|
|
resp: HttpResponse,
|
|
): Promise<OperationAttemptResult> {
|
|
const pursePub = peerPushInitiation.pursePub;
|
|
const errResp = await readTalerErrorResponse(resp);
|
|
if (errResp.code !== TalerErrorCode.EXCHANGE_GENERIC_INSUFFICIENT_FUNDS) {
|
|
await failPeerPushDebitTransaction(ws, pursePub);
|
|
return OperationAttemptResult.finishedEmpty();
|
|
}
|
|
|
|
// 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(peerPushInitiation.amount);
|
|
const sel = peerPushInitiation.coinSel;
|
|
|
|
const repair: PeerCoinRepair = {
|
|
coinPubs: [],
|
|
contribs: [],
|
|
exchangeBaseUrl: peerPushInitiation.exchangeBaseUrl,
|
|
};
|
|
|
|
for (let i = 0; i < sel.coinPubs.length; i++) {
|
|
if (sel.coinPubs[i] != brokenCoinPub) {
|
|
repair.coinPubs.push(sel.coinPubs[i]);
|
|
repair.contribs.push(Amounts.parseOrThrow(sel.contributions[i]));
|
|
}
|
|
}
|
|
|
|
const coinSelRes = await selectPeerCoins(ws, { instructedAmount, repair });
|
|
|
|
if (coinSelRes.type == "failure") {
|
|
// FIXME: Details!
|
|
throw Error(
|
|
"insufficient balance to re-select coins to repair double spending",
|
|
);
|
|
}
|
|
|
|
await ws.db
|
|
.mktx((x) => [x.peerPushPaymentInitiations])
|
|
.runReadWrite(async (tx) => {
|
|
const myPpi = await tx.peerPushPaymentInitiations.get(
|
|
peerPushInitiation.pursePub,
|
|
);
|
|
if (!myPpi) {
|
|
return;
|
|
}
|
|
switch (myPpi.status) {
|
|
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
|
|
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse: {
|
|
const sel = coinSelRes.result;
|
|
myPpi.coinSel = {
|
|
coinPubs: sel.coins.map((x) => x.coinPub),
|
|
contributions: sel.coins.map((x) => x.contribution),
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
await tx.peerPushPaymentInitiations.put(myPpi);
|
|
});
|
|
return OperationAttemptResult.finishedEmpty();
|
|
}
|
|
|
|
async function processPeerPushDebitCreateReserve(
|
|
ws: InternalWalletState,
|
|
peerPushInitiation: PeerPushPaymentInitiationRecord,
|
|
): Promise<OperationAttemptResult> {
|
|
logger.info("processing peer-push-debit pending(create-reserve)");
|
|
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 encryptContractRequest: EncryptContractRequest = {
|
|
contractTerms: peerPushInitiation.contractTerms,
|
|
mergePriv: peerPushInitiation.mergePriv,
|
|
pursePriv: peerPushInitiation.pursePriv,
|
|
pursePub: peerPushInitiation.pursePub,
|
|
contractPriv: peerPushInitiation.contractPriv,
|
|
contractPub: peerPushInitiation.contractPub,
|
|
nonce: peerPushInitiation.contractEncNonce,
|
|
};
|
|
|
|
logger.info(`encrypt contract request: ${j2s(encryptContractRequest)}`);
|
|
|
|
const econtractResp = await ws.cryptoApi.encryptContractForMerge(
|
|
encryptContractRequest,
|
|
);
|
|
|
|
const econtractHash = encodeCrock(
|
|
hash(decodeCrock(econtractResp.econtract.econtract)),
|
|
);
|
|
|
|
logger.info(`econtract hash: ${econtractHash}`);
|
|
|
|
const createPurseUrl = new URL(
|
|
`purses/${peerPushInitiation.pursePub}/create`,
|
|
peerPushInitiation.exchangeBaseUrl,
|
|
);
|
|
|
|
const reqBody = {
|
|
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,
|
|
};
|
|
|
|
logger.info(`request body: ${j2s(reqBody)}`);
|
|
|
|
const httpResp = await ws.http.fetch(createPurseUrl.href, {
|
|
method: "POST",
|
|
body: reqBody,
|
|
});
|
|
|
|
{
|
|
const resp = await httpResp.json();
|
|
logger.info(`resp: ${j2s(resp)}`);
|
|
}
|
|
|
|
switch (httpResp.status) {
|
|
case HttpStatusCode.Ok:
|
|
break;
|
|
case HttpStatusCode.Forbidden: {
|
|
// FIXME: Store this error!
|
|
await failPeerPushDebitTransaction(ws, pursePub);
|
|
return OperationAttemptResult.finishedEmpty();
|
|
}
|
|
case HttpStatusCode.Conflict: {
|
|
// Handle double-spending
|
|
return handlePurseCreationConflict(ws, peerPushInitiation, httpResp);
|
|
}
|
|
default: {
|
|
const errResp = await readTalerErrorResponse(httpResp);
|
|
return {
|
|
type: OperationAttemptResultType.Error,
|
|
errorDetail: errResp,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (httpResp.status !== HttpStatusCode.Ok) {
|
|
// FIXME: do proper error reporting
|
|
throw Error("got error response from exchange");
|
|
}
|
|
|
|
await transitionPeerPushDebitTransaction(ws, pursePub, {
|
|
stFrom: PeerPushPaymentInitiationStatus.PendingCreatePurse,
|
|
stTo: PeerPushPaymentInitiationStatus.PendingReady,
|
|
});
|
|
|
|
return OperationAttemptResult.finishedEmpty();
|
|
}
|
|
|
|
async function processPeerPushDebitAbortingDeletePurse(
|
|
ws: InternalWalletState,
|
|
peerPushInitiation: PeerPushPaymentInitiationRecord,
|
|
): Promise<OperationAttemptResult> {
|
|
const { pursePub, pursePriv } = peerPushInitiation;
|
|
const transactionId = constructTransactionIdentifier({
|
|
tag: TransactionType.PeerPushDebit,
|
|
pursePub,
|
|
});
|
|
|
|
const sigResp = await ws.cryptoApi.signDeletePurse({
|
|
pursePriv,
|
|
});
|
|
const purseUrl = new URL(
|
|
`purses/${pursePub}`,
|
|
peerPushInitiation.exchangeBaseUrl,
|
|
);
|
|
const resp = await ws.http.fetch(purseUrl.href, {
|
|
method: "DELETE",
|
|
headers: {
|
|
"taler-purse-signature": sigResp.sig,
|
|
},
|
|
});
|
|
logger.info(`deleted purse with response status ${resp.status}`);
|
|
|
|
const transitionInfo = await ws.db
|
|
.mktx((x) => [
|
|
x.peerPushPaymentInitiations,
|
|
x.refreshGroups,
|
|
x.denominations,
|
|
x.coinAvailability,
|
|
x.coins,
|
|
])
|
|
.runReadWrite(async (tx) => {
|
|
const ppiRec = await tx.peerPushPaymentInitiations.get(pursePub);
|
|
if (!ppiRec) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
ppiRec.status !== PeerPushPaymentInitiationStatus.AbortingDeletePurse
|
|
) {
|
|
return undefined;
|
|
}
|
|
const currency = Amounts.currencyOf(ppiRec.amount);
|
|
const oldTxState = computePeerPushDebitTransactionState(ppiRec);
|
|
const coinPubs: CoinRefreshRequest[] = [];
|
|
|
|
for (let i = 0; i < ppiRec.coinSel.coinPubs.length; i++) {
|
|
coinPubs.push({
|
|
amount: ppiRec.coinSel.contributions[i],
|
|
coinPub: ppiRec.coinSel.coinPubs[i],
|
|
});
|
|
}
|
|
|
|
const refresh = await createRefreshGroup(
|
|
ws,
|
|
tx,
|
|
currency,
|
|
coinPubs,
|
|
RefreshReason.AbortPeerPushDebit,
|
|
);
|
|
ppiRec.status = PeerPushPaymentInitiationStatus.AbortingRefresh;
|
|
ppiRec.abortRefreshGroupId = refresh.refreshGroupId;
|
|
await tx.peerPushPaymentInitiations.put(ppiRec);
|
|
const newTxState = computePeerPushDebitTransactionState(ppiRec);
|
|
return {
|
|
oldTxState,
|
|
newTxState,
|
|
};
|
|
});
|
|
notifyTransition(ws, transactionId, transitionInfo);
|
|
|
|
return OperationAttemptResult.pendingEmpty();
|
|
}
|
|
|
|
interface SimpleTransition {
|
|
stFrom: PeerPushPaymentInitiationStatus;
|
|
stTo: PeerPushPaymentInitiationStatus;
|
|
}
|
|
|
|
async function transitionPeerPushDebitTransaction(
|
|
ws: InternalWalletState,
|
|
pursePub: string,
|
|
transitionSpec: SimpleTransition,
|
|
): 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 !== transitionSpec.stFrom) {
|
|
return undefined;
|
|
}
|
|
const oldTxState = computePeerPushDebitTransactionState(ppiRec);
|
|
ppiRec.status = transitionSpec.stTo;
|
|
await tx.peerPushPaymentInitiations.put(ppiRec);
|
|
const newTxState = computePeerPushDebitTransactionState(ppiRec);
|
|
return {
|
|
oldTxState,
|
|
newTxState,
|
|
};
|
|
});
|
|
notifyTransition(ws, transactionId, transitionInfo);
|
|
}
|
|
|
|
async function processPeerPushDebitAbortingRefresh(
|
|
ws: InternalWalletState,
|
|
peerPushInitiation: PeerPushPaymentInitiationRecord,
|
|
): Promise<OperationAttemptResult> {
|
|
const pursePub = peerPushInitiation.pursePub;
|
|
const abortRefreshGroupId = peerPushInitiation.abortRefreshGroupId;
|
|
checkLogicInvariant(!!abortRefreshGroupId);
|
|
const transactionId = constructTransactionIdentifier({
|
|
tag: TransactionType.PeerPushDebit,
|
|
pursePub: peerPushInitiation.pursePub,
|
|
});
|
|
const transitionInfo = await ws.db
|
|
.mktx((x) => [x.refreshGroups, x.peerPushPaymentInitiations])
|
|
.runReadWrite(async (tx) => {
|
|
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
|
|
let newOpState: PeerPushPaymentInitiationStatus | 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 = PeerPushPaymentInitiationStatus.Failed;
|
|
} else {
|
|
if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
|
|
newOpState = PeerPushPaymentInitiationStatus.Aborted;
|
|
} else if (
|
|
refreshGroup.operationStatus === RefreshOperationStatus.Failed
|
|
) {
|
|
newOpState = PeerPushPaymentInitiationStatus.Failed;
|
|
}
|
|
}
|
|
if (newOpState) {
|
|
const newDg = await tx.peerPushPaymentInitiations.get(pursePub);
|
|
if (!newDg) {
|
|
return;
|
|
}
|
|
const oldTxState = computePeerPushDebitTransactionState(newDg);
|
|
newDg.status = newOpState;
|
|
const newTxState = computePeerPushDebitTransactionState(newDg);
|
|
await tx.peerPushPaymentInitiations.put(newDg);
|
|
return { oldTxState, newTxState };
|
|
}
|
|
return undefined;
|
|
});
|
|
notifyTransition(ws, transactionId, transitionInfo);
|
|
// FIXME: Shouldn't this be finished in some cases?!
|
|
return OperationAttemptResult.pendingEmpty();
|
|
}
|
|
|
|
/**
|
|
* Process the "pending(ready)" state of a peer-push-debit transaction.
|
|
*/
|
|
async function processPeerPushDebitReady(
|
|
ws: InternalWalletState,
|
|
peerPushInitiation: PeerPushPaymentInitiationRecord,
|
|
): Promise<OperationAttemptResult> {
|
|
logger.info("processing peer-push-debit pending(ready)");
|
|
const pursePub = peerPushInitiation.pursePub;
|
|
const retryTag = constructTaskIdentifier({
|
|
tag: PendingTaskType.PeerPushDebit,
|
|
pursePub,
|
|
});
|
|
runLongpollAsync(ws, retryTag, async (ct) => {
|
|
const mergeUrl = new URL(
|
|
`purses/${pursePub}/merge`,
|
|
peerPushInitiation.exchangeBaseUrl,
|
|
);
|
|
mergeUrl.searchParams.set("timeout_ms", "30000");
|
|
logger.info(`long-polling on purse status at ${mergeUrl.href}`);
|
|
const resp = await ws.http.fetch(mergeUrl.href, {
|
|
// timeout: getReserveRequestTimeout(withdrawalGroup),
|
|
cancellationToken: ct,
|
|
});
|
|
if (resp.status === HttpStatusCode.Ok) {
|
|
const purseStatus = await readSuccessResponseJsonOrThrow(
|
|
resp,
|
|
codecForExchangePurseStatus(),
|
|
);
|
|
logger.info(`got purse status ${purseStatus}`);
|
|
if (purseStatus.merge_timestamp) {
|
|
await transitionPeerPushDebitTransaction(
|
|
ws,
|
|
peerPushInitiation.pursePub,
|
|
{
|
|
stFrom: PeerPushPaymentInitiationStatus.PendingReady,
|
|
stTo: PeerPushPaymentInitiationStatus.Done,
|
|
},
|
|
);
|
|
return {
|
|
ready: true,
|
|
};
|
|
}
|
|
} else if (resp.status === HttpStatusCode.Gone) {
|
|
await transitionPeerPushDebitTransaction(
|
|
ws,
|
|
peerPushInitiation.pursePub,
|
|
{
|
|
stFrom: PeerPushPaymentInitiationStatus.PendingReady,
|
|
stTo: PeerPushPaymentInitiationStatus.Expired,
|
|
},
|
|
);
|
|
return {
|
|
ready: true,
|
|
};
|
|
}
|
|
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);
|
|
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
|
|
return processPeerPushDebitAbortingDeletePurse(ws, peerPushInitiation);
|
|
case PeerPushPaymentInitiationStatus.AbortingRefresh:
|
|
return processPeerPushDebitAbortingRefresh(ws, peerPushInitiation);
|
|
default: {
|
|
const txState = computePeerPushDebitTransactionState(peerPushInitiation);
|
|
logger.warn(
|
|
`not processing peer-push-debit transaction in state ${j2s(txState)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
|
|
const pursePub = pursePair.pub;
|
|
|
|
const transactionId = constructTaskIdentifier({
|
|
tag: PendingTaskType.PeerPushDebit,
|
|
pursePub,
|
|
});
|
|
|
|
const contractEncNonce = encodeCrock(getRandomBytes(24));
|
|
|
|
const transitionInfo = 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,
|
|
});
|
|
|
|
const ppi: PeerPushPaymentInitiationRecord = {
|
|
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,
|
|
contractEncNonce,
|
|
coinSel: {
|
|
coinPubs: sel.coins.map((x) => x.coinPub),
|
|
contributions: sel.coins.map((x) => x.contribution),
|
|
},
|
|
totalCost: Amounts.stringify(totalAmount),
|
|
};
|
|
|
|
await tx.peerPushPaymentInitiations.add(ppi);
|
|
|
|
await tx.contractTerms.put({
|
|
h: hContractTerms,
|
|
contractTermsRaw: contractTerms,
|
|
});
|
|
|
|
const newTxState = computePeerPushDebitTransactionState(ppi);
|
|
return {
|
|
oldTxState: { major: TransactionMajorState.None },
|
|
newTxState,
|
|
};
|
|
});
|
|
notifyTransition(ws, transactionId, transitionInfo);
|
|
|
|
return {
|
|
contractPriv: contractKeyPair.priv,
|
|
mergePriv: mergePair.priv,
|
|
pursePub: pursePair.pub,
|
|
exchangeBaseUrl: coinSelRes.result.exchangeBaseUrl,
|
|
talerUri: stringifyTalerUri({
|
|
type: TalerUriAction.PayPush,
|
|
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.Expired:
|
|
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:
|
|
case PeerPushPaymentInitiationStatus.Expired:
|
|
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 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: What to do about the refresh group?
|
|
newStatus = PeerPushPaymentInitiationStatus.Failed;
|
|
break;
|
|
case PeerPushPaymentInitiationStatus.AbortingDeletePurse:
|
|
case PeerPushPaymentInitiationStatus.SuspendedAbortingDeletePurse:
|
|
case PeerPushPaymentInitiationStatus.PendingReady:
|
|
case PeerPushPaymentInitiationStatus.SuspendedReady:
|
|
case PeerPushPaymentInitiationStatus.SuspendedCreatePurse:
|
|
case PeerPushPaymentInitiationStatus.PendingCreatePurse:
|
|
newStatus = PeerPushPaymentInitiationStatus.Failed;
|
|
break;
|
|
case PeerPushPaymentInitiationStatus.Done:
|
|
case PeerPushPaymentInitiationStatus.Aborted:
|
|
case PeerPushPaymentInitiationStatus.Failed:
|
|
case PeerPushPaymentInitiationStatus.Expired:
|
|
// 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:
|
|
case PeerPushPaymentInitiationStatus.Expired:
|
|
// 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:
|
|
case PeerPushPaymentInitiationStatus.Expired:
|
|
// 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,
|
|
};
|
|
case PeerPushPaymentInitiationStatus.Expired:
|
|
return {
|
|
major: TransactionMajorState.Expired,
|
|
};
|
|
}
|
|
}
|