/*
This file is part of GNU Taler
(C) 2021-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
*/
/**
* Implementation of the deposit transaction.
*/
/**
* Imports.
*/
import {
AbsoluteTime,
AmountJson,
Amounts,
BatchDepositRequestCoin,
CancellationToken,
CoinRefreshRequest,
CreateDepositGroupRequest,
CreateDepositGroupResponse,
DepositGroupFees,
DepositTransactionTrackingState,
Duration,
ExchangeBatchDepositRequest,
ExchangeHandle,
ExchangeRefundRequest,
HttpStatusCode,
Logger,
MerchantContractTerms,
NotificationType,
PrepareDepositRequest,
PrepareDepositResponse,
RefreshReason,
SelectedProspectiveCoin,
TalerError,
TalerErrorCode,
TalerPreciseTimestamp,
TalerProtocolTimestamp,
TrackTransaction,
Transaction,
TransactionAction,
TransactionIdStr,
TransactionMajorState,
TransactionMinorState,
TransactionState,
TransactionType,
URL,
assertUnreachable,
canonicalJson,
checkDbInvariant,
checkLogicInvariant,
codecForAccountKycStatus,
codecForBatchDepositSuccess,
codecForLegitimizationNeededResponse,
codecForTackTransactionAccepted,
codecForTackTransactionWired,
encodeCrock,
getRandomBytes,
hashTruncate32,
hashWire,
j2s,
parsePaytoUri,
stringToBytes,
} from "@gnu-taler/taler-util";
import {
readResponseJsonOrThrow,
readSuccessResponseJsonOrThrow,
readTalerErrorResponse,
throwUnexpectedRequestError,
} from "@gnu-taler/taler-util/http";
import { selectPayCoins, selectPayCoinsInTx } from "./coinSelection.js";
import {
PendingTaskType,
TaskIdStr,
TaskRunResult,
TombstoneTag,
TransactionContext,
constructTaskIdentifier,
runWithClientCancellation,
spendCoins,
} from "./common.js";
import {
DepositElementStatus,
DepositGroupRecord,
DepositInfoPerExchange,
DepositOperationStatus,
DepositTrackingInfo,
RefreshOperationStatus,
WalletDbAllStoresReadOnlyTransaction,
WalletDbReadWriteTransaction,
timestampAbsoluteFromDb,
timestampPreciseFromDb,
timestampPreciseToDb,
timestampProtocolFromDb,
timestampProtocolToDb,
} from "./db.js";
import {
getExchangeWireDetailsInTx,
getExchangeWireFee,
getScopeForAllExchanges,
} from "./exchanges.js";
import { EddsaKeypairStrings } from "./index.js";
import {
extractContractData,
generateDepositPermissions,
getTotalPaymentCost,
} from "./pay-merchant.js";
import {
CreateRefreshGroupResult,
createRefreshGroup,
getTotalRefreshCost,
} from "./refresh.js";
import {
constructTransactionIdentifier,
isUnsuccessfulTransaction,
notifyTransition,
parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext, getDenomInfo } from "./wallet.js";
/**
* Logger.
*/
const logger = new Logger("deposits.ts");
export class DepositTransactionContext implements TransactionContext {
readonly transactionId: TransactionIdStr;
readonly taskId: TaskIdStr;
constructor(
public wex: WalletExecutionContext,
public depositGroupId: string,
) {
this.transactionId = constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId,
});
this.taskId = constructTaskIdentifier({
tag: PendingTaskType.Deposit,
depositGroupId,
});
}
/**
* Get the full transaction details for the transaction.
*
* Returns undefined if the transaction is in a state where we do not have a
* transaction item (e.g. if it was deleted).
*/
async lookupFullTransaction(
tx: WalletDbAllStoresReadOnlyTransaction,
): Promise {
const dg = await tx.depositGroups.get(this.depositGroupId);
if (!dg) {
return undefined;
}
const ort = await tx.operationRetries.get(this.taskId);
let deposited = true;
if (dg.statusPerCoin) {
for (const d of dg.statusPerCoin) {
if (d == DepositElementStatus.DepositPending) {
deposited = false;
}
}
} else {
deposited = false;
}
const trackingState: DepositTransactionTrackingState[] = [];
for (const ts of Object.values(dg.trackingState ?? {})) {
trackingState.push({
amountRaw: ts.amountRaw,
timestampExecuted: timestampProtocolFromDb(ts.timestampExecuted),
wireFee: ts.wireFee,
wireTransferId: ts.wireTransferId,
});
}
let wireTransferProgress = 0;
if (dg.statusPerCoin) {
wireTransferProgress =
(100 *
dg.statusPerCoin.reduce(
(prev, cur) => prev + (cur === DepositElementStatus.Wired ? 1 : 0),
0,
)) /
dg.statusPerCoin.length;
}
const txState = computeDepositTransactionStatus(dg);
return {
type: TransactionType.Deposit,
txState,
scopes: await getScopeForAllExchanges(
tx,
!dg.infoPerExchange ? [] : Object.keys(dg.infoPerExchange),
),
txActions: computeDepositTransactionActions(dg),
amountRaw: Amounts.stringify(dg.counterpartyEffectiveDepositAmount),
amountEffective: isUnsuccessfulTransaction(txState)
? Amounts.stringify(Amounts.zeroOfAmount(dg.totalPayCost))
: Amounts.stringify(dg.totalPayCost),
timestamp: timestampPreciseFromDb(dg.timestampCreated),
targetPaytoUri: dg.wire.payto_uri,
wireTransferDeadline: timestampProtocolFromDb(dg.wireTransferDeadline),
transactionId: constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId: dg.depositGroupId,
}),
wireTransferProgress,
depositGroupId: dg.depositGroupId,
trackingState,
deposited,
kycPaytoHash: dg.kycInfo?.paytoHash,
kycAccessToken: dg.kycInfo?.accessToken,
kycUrl: dg.kycInfo
? new URL(
`kyc-spa/${dg.kycInfo.accessToken}`,
dg.kycInfo.exchangeBaseUrl,
).href
: undefined,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
/**
* Update the metadata of the transaction in the database.
*/
async updateTransactionMeta(
tx: WalletDbReadWriteTransaction<["depositGroups", "transactionsMeta"]>,
): Promise {
const depositRec = await tx.depositGroups.get(this.depositGroupId);
if (!depositRec) {
await tx.transactionsMeta.delete(this.transactionId);
return;
}
await tx.transactionsMeta.put({
transactionId: this.transactionId,
status: depositRec.operationStatus,
timestamp: depositRec.timestampCreated,
currency: depositRec.currency,
exchanges: Object.keys(depositRec.infoPerExchange ?? {}),
});
}
async deleteTransaction(): Promise {
const depositGroupId = this.depositGroupId;
const ws = this.wex;
// FIXME: We should check first if we are in a final state
// where deletion is allowed.
await ws.db.runReadWriteTx(
{ storeNames: ["depositGroups", "tombstones", "transactionsMeta"] },
async (tx) => {
const tipRecord = await tx.depositGroups.get(depositGroupId);
if (tipRecord) {
await tx.depositGroups.delete(depositGroupId);
await tx.tombstones.put({
id: TombstoneTag.DeleteDepositGroup + ":" + depositGroupId,
});
await this.updateTransactionMeta(tx);
}
},
);
return;
}
async suspendTransaction(): Promise {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
logger.warn(
`can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
);
return undefined;
}
const oldState = computeDepositTransactionStatus(dg);
let newOpStatus: DepositOperationStatus | undefined;
switch (dg.operationStatus) {
case DepositOperationStatus.Aborted:
case DepositOperationStatus.Failed:
case DepositOperationStatus.Finished:
case DepositOperationStatus.SuspendedAborting:
case DepositOperationStatus.SuspendedAggregateKyc:
case DepositOperationStatus.SuspendedDeposit:
case DepositOperationStatus.SuspendedDepositKyc:
case DepositOperationStatus.SuspendedTrack:
break;
case DepositOperationStatus.PendingDepositKyc:
newOpStatus = DepositOperationStatus.SuspendedDepositKyc;
break;
case DepositOperationStatus.PendingDeposit:
newOpStatus = DepositOperationStatus.SuspendedDeposit;
break;
case DepositOperationStatus.PendingAggregateKyc:
newOpStatus = DepositOperationStatus.SuspendedAggregateKyc;
break;
case DepositOperationStatus.PendingTrack:
newOpStatus = DepositOperationStatus.SuspendedTrack;
break;
case DepositOperationStatus.Aborting:
newOpStatus = DepositOperationStatus.SuspendedAborting;
break;
default:
assertUnreachable(dg.operationStatus);
}
if (!newOpStatus) {
return undefined;
}
dg.operationStatus = newOpStatus;
await tx.depositGroups.put(dg);
await this.updateTransactionMeta(tx);
return {
oldTxState: oldState,
newTxState: computeDepositTransactionStatus(dg),
};
},
);
wex.taskScheduler.stopShepherdTask(retryTag);
notifyTransition(wex, transactionId, transitionInfo);
}
async abortTransaction(): Promise {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
logger.warn(
`can't suspend deposit group, depositGroupId=${depositGroupId} not found`,
);
return undefined;
}
const oldState = computeDepositTransactionStatus(dg);
switch (dg.operationStatus) {
case DepositOperationStatus.Finished:
return undefined;
case DepositOperationStatus.PendingDeposit:
case DepositOperationStatus.SuspendedDeposit: {
dg.operationStatus = DepositOperationStatus.Aborting;
await tx.depositGroups.put(dg);
await this.updateTransactionMeta(tx);
return {
oldTxState: oldState,
newTxState: computeDepositTransactionStatus(dg),
};
}
}
return undefined;
},
);
wex.taskScheduler.stopShepherdTask(retryTag);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(retryTag);
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
}
async resumeTransaction(): Promise {
const { wex, depositGroupId, transactionId, taskId: retryTag } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
logger.warn(
`can't resume deposit group, depositGroupId=${depositGroupId} not found`,
);
return;
}
const oldState = computeDepositTransactionStatus(dg);
let newOpStatus: DepositOperationStatus | undefined;
switch (dg.operationStatus) {
case DepositOperationStatus.Aborted:
case DepositOperationStatus.Aborting:
case DepositOperationStatus.Failed:
case DepositOperationStatus.Finished:
case DepositOperationStatus.PendingAggregateKyc:
case DepositOperationStatus.PendingDeposit:
case DepositOperationStatus.PendingDepositKyc:
case DepositOperationStatus.PendingTrack:
break;
case DepositOperationStatus.SuspendedDepositKyc:
newOpStatus = DepositOperationStatus.PendingDepositKyc;
break;
case DepositOperationStatus.SuspendedDeposit:
newOpStatus = DepositOperationStatus.PendingDeposit;
break;
case DepositOperationStatus.SuspendedAborting:
newOpStatus = DepositOperationStatus.Aborting;
break;
case DepositOperationStatus.SuspendedAggregateKyc:
newOpStatus = DepositOperationStatus.PendingAggregateKyc;
break;
case DepositOperationStatus.SuspendedTrack:
newOpStatus = DepositOperationStatus.PendingTrack;
break;
default:
assertUnreachable(dg.operationStatus);
}
if (!newOpStatus) {
return undefined;
}
dg.operationStatus = newOpStatus;
await tx.depositGroups.put(dg);
await this.updateTransactionMeta(tx);
return {
oldTxState: oldState,
newTxState: computeDepositTransactionStatus(dg),
};
},
);
notifyTransition(wex, transactionId, transitionInfo);
wex.taskScheduler.startShepherdTask(retryTag);
}
async failTransaction(): Promise {
const { wex, depositGroupId, transactionId, taskId } = this;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
logger.warn(
`can't cancel aborting deposit group, depositGroupId=${depositGroupId} not found`,
);
return undefined;
}
const oldState = computeDepositTransactionStatus(dg);
switch (dg.operationStatus) {
case DepositOperationStatus.SuspendedAborting:
case DepositOperationStatus.Aborting: {
dg.operationStatus = DepositOperationStatus.Failed;
await tx.depositGroups.put(dg);
await this.updateTransactionMeta(tx);
return {
oldTxState: oldState,
newTxState: computeDepositTransactionStatus(dg),
};
}
}
return undefined;
},
);
wex.taskScheduler.stopShepherdTask(taskId);
notifyTransition(wex, transactionId, transitionInfo);
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
}
}
/**
* Get the (DD37-style) transaction status based on the
* database record of a deposit group.
*/
export function computeDepositTransactionStatus(
dg: DepositGroupRecord,
): TransactionState {
switch (dg.operationStatus) {
case DepositOperationStatus.Finished:
return {
major: TransactionMajorState.Done,
};
case DepositOperationStatus.PendingDeposit:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Deposit,
};
case DepositOperationStatus.PendingAggregateKyc:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.KycRequired,
};
case DepositOperationStatus.PendingTrack:
return {
major: TransactionMajorState.Pending,
minor: TransactionMinorState.Track,
};
case DepositOperationStatus.SuspendedAggregateKyc:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.KycRequired,
};
case DepositOperationStatus.SuspendedTrack:
return {
major: TransactionMajorState.Suspended,
minor: TransactionMinorState.Track,
};
case DepositOperationStatus.SuspendedDeposit:
return {
major: TransactionMajorState.Suspended,
};
case DepositOperationStatus.Aborting:
return {
major: TransactionMajorState.Aborting,
};
case DepositOperationStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
case DepositOperationStatus.Failed:
return {
major: TransactionMajorState.Failed,
};
case DepositOperationStatus.SuspendedAborting:
return {
major: TransactionMajorState.SuspendedAborting,
};
case DepositOperationStatus.PendingDepositKyc:
return {
major: TransactionMajorState.Pending,
// We lie to the UI by hiding the specific KYC state.
minor: TransactionMinorState.KycRequired,
};
case DepositOperationStatus.SuspendedDepositKyc:
return {
major: TransactionMajorState.Suspended,
// We lie to the UI by hiding the specific KYC state.
minor: TransactionMinorState.KycRequired,
};
default:
assertUnreachable(dg.operationStatus);
}
}
/**
* Compute the possible actions possible on a deposit transaction
* based on the current transaction state.
*/
export function computeDepositTransactionActions(
dg: DepositGroupRecord,
): TransactionAction[] {
switch (dg.operationStatus) {
case DepositOperationStatus.Finished:
return [TransactionAction.Delete];
case DepositOperationStatus.PendingDeposit:
return [
TransactionAction.Retry,
TransactionAction.Suspend,
TransactionAction.Abort,
];
case DepositOperationStatus.SuspendedDeposit:
return [TransactionAction.Resume];
case DepositOperationStatus.Aborting:
return [
TransactionAction.Retry,
TransactionAction.Fail,
TransactionAction.Suspend,
];
case DepositOperationStatus.Aborted:
return [TransactionAction.Delete];
case DepositOperationStatus.Failed:
return [TransactionAction.Delete];
case DepositOperationStatus.SuspendedAborting:
return [TransactionAction.Resume, TransactionAction.Fail];
case DepositOperationStatus.PendingAggregateKyc:
return [
TransactionAction.Retry,
TransactionAction.Suspend,
TransactionAction.Fail,
];
case DepositOperationStatus.PendingTrack:
return [
TransactionAction.Retry,
TransactionAction.Suspend,
TransactionAction.Abort,
];
case DepositOperationStatus.SuspendedAggregateKyc:
return [TransactionAction.Resume, TransactionAction.Fail];
case DepositOperationStatus.SuspendedTrack:
return [TransactionAction.Resume, TransactionAction.Abort];
case DepositOperationStatus.PendingDepositKyc:
return [TransactionAction.Resume, TransactionAction.Abort];
case DepositOperationStatus.SuspendedDepositKyc:
return [TransactionAction.Suspend, TransactionAction.Abort];
default:
assertUnreachable(dg.operationStatus);
}
}
async function refundDepositGroup(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise {
const statusPerCoin = depositGroup.statusPerCoin;
const payCoinSelection = depositGroup.payCoinSelection;
if (!statusPerCoin) {
throw Error(
"unable to refund deposit group without coin selection (status missing)",
);
}
if (!payCoinSelection) {
throw Error(
"unable to refund deposit group without coin selection (selection missing)",
);
}
const newTxPerCoin = [...statusPerCoin];
logger.info(`status per coin: ${j2s(depositGroup.statusPerCoin)}`);
for (let i = 0; i < statusPerCoin.length; i++) {
const st = statusPerCoin[i];
switch (st) {
case DepositElementStatus.RefundFailed:
case DepositElementStatus.RefundSuccess:
break;
default: {
const coinPub = payCoinSelection.coinPubs[i];
const coinExchange = await wex.db.runReadOnlyTx(
{ storeNames: ["coins"] },
async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
return coinRecord.exchangeBaseUrl;
},
);
const refundAmount = payCoinSelection.coinContributions[i];
// We use a constant refund transaction ID, since there can
// only be one refund.
const rtid = 1;
const sig = await wex.cryptoApi.signRefund({
coinPub,
contractTermsHash: depositGroup.contractTermsHash,
merchantPriv: depositGroup.merchantPriv,
merchantPub: depositGroup.merchantPub,
refundAmount: refundAmount,
rtransactionId: rtid,
});
const refundReq: ExchangeRefundRequest = {
h_contract_terms: depositGroup.contractTermsHash,
merchant_pub: depositGroup.merchantPub,
merchant_sig: sig.sig,
refund_amount: refundAmount,
rtransaction_id: rtid,
};
const refundUrl = new URL(`coins/${coinPub}/refund`, coinExchange);
const httpResp = await wex.http.fetch(refundUrl.href, {
method: "POST",
body: refundReq,
cancellationToken: wex.cancellationToken,
});
logger.info(
`coin ${i} refund HTTP status for coin: ${httpResp.status}`,
);
let newStatus: DepositElementStatus;
if (httpResp.status === 200) {
// FIXME: validate response
newStatus = DepositElementStatus.RefundSuccess;
} else {
// FIXME: Store problem somewhere!
newStatus = DepositElementStatus.RefundFailed;
}
// FIXME: Handle case where refund request needs to be tried again
newTxPerCoin[i] = newStatus;
break;
}
}
}
let isDone = true;
for (let i = 0; i < newTxPerCoin.length; i++) {
if (
newTxPerCoin[i] != DepositElementStatus.RefundFailed &&
newTxPerCoin[i] != DepositElementStatus.RefundSuccess
) {
isDone = false;
}
}
const currency = Amounts.currencyOf(depositGroup.totalPayCost);
const ctx = new DepositTransactionContext(wex, depositGroup.depositGroupId);
const res = await wex.db.runReadWriteTx(
{
storeNames: [
"coinAvailability",
"coinHistory",
"coins",
"denominations",
"depositGroups",
"refreshGroups",
"refreshSessions",
"transactionsMeta",
],
},
async (tx) => {
const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
if (!newDg) {
return;
}
newDg.statusPerCoin = newTxPerCoin;
const refreshCoins: CoinRefreshRequest[] = [];
for (let i = 0; i < newTxPerCoin.length; i++) {
refreshCoins.push({
amount: payCoinSelection.coinContributions[i],
coinPub: payCoinSelection.coinPubs[i],
});
}
let refreshRes: CreateRefreshGroupResult | undefined = undefined;
if (isDone) {
refreshRes = await createRefreshGroup(
wex,
tx,
currency,
refreshCoins,
RefreshReason.AbortDeposit,
constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId: newDg.depositGroupId,
}),
);
newDg.abortRefreshGroupId = refreshRes.refreshGroupId;
}
await tx.depositGroups.put(newDg);
await ctx.updateTransactionMeta(tx);
return { refreshRes };
},
);
if (res?.refreshRes) {
for (const notif of res.refreshRes.notifications) {
wex.ws.notify(notif);
}
}
return TaskRunResult.backoff();
}
/**
* Check whether the refresh associated with the
* aborting deposit group is done.
*
* If done, mark the deposit transaction as aborted.
*
* Otherwise continue waiting.
*
* FIXME: Wait for the refresh group notifications instead of periodically
* checking the refresh group status.
* FIXME: This is just one transaction, can't we do this in the initial
* transaction of processDepositGroup?
*/
async function waitForRefreshOnDepositGroup(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise {
const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
checkLogicInvariant(!!abortRefreshGroupId);
const ctx = new DepositTransactionContext(wex, depositGroup.depositGroupId);
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "refreshGroups", "transactionsMeta"] },
async (tx) => {
const refreshGroup = await tx.refreshGroups.get(abortRefreshGroupId);
let newOpState: DepositOperationStatus | undefined;
if (!refreshGroup) {
// Maybe it got manually deleted? Means that we should
// just go into aborted.
logger.warn("no aborting refresh group found for deposit group");
newOpState = DepositOperationStatus.Aborted;
} else {
if (refreshGroup.operationStatus === RefreshOperationStatus.Finished) {
newOpState = DepositOperationStatus.Aborted;
} else if (
refreshGroup.operationStatus === RefreshOperationStatus.Failed
) {
newOpState = DepositOperationStatus.Aborted;
}
}
if (newOpState) {
const newDg = await tx.depositGroups.get(depositGroup.depositGroupId);
if (!newDg) {
return;
}
const oldTxState = computeDepositTransactionStatus(newDg);
newDg.operationStatus = newOpState;
const newTxState = computeDepositTransactionStatus(newDg);
await tx.depositGroups.put(newDg);
await ctx.updateTransactionMeta(tx);
return { oldTxState, newTxState };
}
return undefined;
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: ctx.transactionId,
});
return TaskRunResult.backoff();
}
async function processDepositGroupAborting(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise {
logger.info("processing deposit tx in 'aborting'");
const abortRefreshGroupId = depositGroup.abortRefreshGroupId;
if (!abortRefreshGroupId) {
logger.info("refunding deposit group");
return refundDepositGroup(wex, depositGroup);
}
logger.info("waiting for refresh");
return waitForRefreshOnDepositGroup(wex, depositGroup);
}
/**
* Process the transaction in states where KYC is required.
* Used for both the deposit KYC and aggregate KYC.
*/
async function processDepositGroupPendingKyc(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise {
const { depositGroupId } = depositGroup;
const transactionId = constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId,
});
const kycInfo = depositGroup.kycInfo;
if (!kycInfo) {
throw Error("invalid DB state, in pending(kyc), but no kycInfo present");
}
const lastReserveKeypair = await getLastWithdrawalKeyPair(
wex,
kycInfo.exchangeBaseUrl,
);
if (!lastReserveKeypair) {
// Need to do a KYC transfer
throw Error("not supported yet");
}
const sigResp = await wex.cryptoApi.signWalletKycAuth({
accountPriv: lastReserveKeypair.priv,
accountPub: lastReserveKeypair.pub,
});
const url = new URL(
`kyc-check/${kycInfo.paytoHash}`,
kycInfo.exchangeBaseUrl,
);
const kycStatusRes = await wex.ws.runLongpollQueueing(
wex,
url.hostname,
async (timeoutMs) => {
url.searchParams.set("timeout_ms", `${timeoutMs}`);
logger.info(`kyc url ${url.href}`);
return await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
headers: {
["Account-Owner-Signature"]: sigResp.sig,
},
});
},
);
const ctx = new DepositTransactionContext(wex, depositGroupId);
if (
kycStatusRes.status === HttpStatusCode.Ok ||
kycStatusRes.status === HttpStatusCode.NoContent
) {
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const newDg = await tx.depositGroups.get(depositGroupId);
if (!newDg) {
return;
}
const oldTxState = computeDepositTransactionStatus(newDg);
switch (newDg.operationStatus) {
case DepositOperationStatus.PendingAggregateKyc:
newDg.operationStatus = DepositOperationStatus.PendingTrack;
break;
case DepositOperationStatus.PendingDeposit:
newDg.operationStatus = DepositOperationStatus.PendingDeposit;
break;
default:
return;
}
await tx.depositGroups.put(newDg);
await ctx.updateTransactionMeta(tx);
const newTxState = computeDepositTransactionStatus(newDg);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, transactionId, transitionInfo);
} else if (kycStatusRes.status === HttpStatusCode.Accepted) {
logger.info("kyc still pending");
} else {
throw Error(`unexpected response from kyc-check (${kycStatusRes.status})`);
}
return TaskRunResult.backoff();
}
/**
* Finds the reserve key pair of the most recent withdrawal
* with the given exchange.
* Returns undefined if no such withdrawal exists.
*/
async function getLastWithdrawalKeyPair(
wex: WalletExecutionContext,
exchangeBaseUrl: string,
): Promise {
let candidateTimestamp: AbsoluteTime | undefined = undefined;
let candidateRes: EddsaKeypairStrings | undefined = undefined;
await wex.db.runAllStoresReadOnlyTx({}, async (tx) => {
const withdrawalRecs =
await tx.withdrawalGroups.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
for (const rec of withdrawalRecs) {
if (!rec.timestampFinish) {
continue;
}
const currTimestamp = timestampAbsoluteFromDb(rec.timestampFinish);
if (
candidateTimestamp == null ||
AbsoluteTime.cmp(currTimestamp, candidateTimestamp) > 0
) {
candidateTimestamp = currTimestamp;
candidateRes = {
priv: rec.reservePriv,
pub: rec.reservePub,
};
}
}
});
return candidateRes;
}
/**
* Tracking information from the exchange indicated that
* KYC is required. We need to check the KYC info
* and transition the transaction to the KYC required state.
*/
async function transitionToKycRequired(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
kycPaytoHash: string,
exchangeUrl: string,
): Promise {
const { depositGroupId } = depositGroup;
const ctx = new DepositTransactionContext(wex, depositGroupId);
const lastReserveKeypair = await getLastWithdrawalKeyPair(wex, exchangeUrl);
if (!lastReserveKeypair) {
// Need to do a KYC transfer
throw Error("not supported yet");
}
const sigResp = await wex.cryptoApi.signWalletKycAuth({
accountPriv: lastReserveKeypair.priv,
accountPub: lastReserveKeypair.pub,
});
const url = new URL(`kyc-check/${kycPaytoHash}`, exchangeUrl);
logger.info(`kyc url ${url.href}`);
const kycStatusResp = await wex.http.fetch(url.href, {
method: "GET",
headers: {
["Account-Owner-Signature"]: sigResp.sig,
},
});
logger.trace(`response status of initial kyc-check: ${kycStatusResp.status}`);
if (kycStatusResp.status === HttpStatusCode.Ok) {
logger.warn("kyc requested, but already fulfilled");
return TaskRunResult.backoff();
} else if (kycStatusResp.status === HttpStatusCode.Accepted) {
const statusResp = await readResponseJsonOrThrow(
kycStatusResp,
codecForAccountKycStatus(),
);
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return undefined;
}
const oldTxState = computeDepositTransactionStatus(dg);
switch (dg.operationStatus) {
case DepositOperationStatus.PendingTrack:
dg.operationStatus = DepositOperationStatus.PendingAggregateKyc;
break;
case DepositOperationStatus.PendingDeposit:
dg.operationStatus = DepositOperationStatus.PendingDepositKyc;
break;
default:
return;
}
dg.kycInfo = {
exchangeBaseUrl: exchangeUrl,
paytoHash: kycPaytoHash,
accessToken: statusResp.access_token,
};
await tx.depositGroups.put(dg);
await ctx.updateTransactionMeta(tx);
const newTxState = computeDepositTransactionStatus(dg);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
return TaskRunResult.progress();
} else {
throwUnexpectedRequestError(
kycStatusResp,
await readTalerErrorResponse(kycStatusResp),
);
}
}
async function processDepositGroupPendingTrack(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
): Promise {
const statusPerCoin = depositGroup.statusPerCoin;
const payCoinSelection = depositGroup.payCoinSelection;
if (!statusPerCoin) {
throw Error(
"unable to refund deposit group without coin selection (status missing)",
);
}
if (!payCoinSelection) {
throw Error(
"unable to refund deposit group without coin selection (selection missing)",
);
}
const { depositGroupId } = depositGroup;
const ctx = new DepositTransactionContext(wex, depositGroupId);
for (let i = 0; i < statusPerCoin.length; i++) {
const coinPub = payCoinSelection.coinPubs[i];
// FIXME: Make the URL part of the coin selection?
const exchangeBaseUrl = await wex.db.runReadWriteTx(
{ storeNames: ["coins"] },
async (tx) => {
const coinRecord = await tx.coins.get(coinPub);
checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`);
return coinRecord.exchangeBaseUrl;
},
);
let updatedTxStatus: DepositElementStatus | undefined = undefined;
let newWiredCoin:
| {
id: string;
value: DepositTrackingInfo;
}
| undefined;
if (statusPerCoin[i] !== DepositElementStatus.Wired) {
const track = await trackDeposit(
wex,
depositGroup,
coinPub,
exchangeBaseUrl,
);
logger.trace(`track response: ${j2s(track)}`);
if (track.type === "accepted") {
if (!track.kyc_ok && track.requirement_row !== undefined) {
const paytoHash = encodeCrock(
hashTruncate32(stringToBytes(depositGroup.wire.payto_uri + "\0")),
);
return transitionToKycRequired(
wex,
depositGroup,
paytoHash,
exchangeBaseUrl,
);
} else {
updatedTxStatus = DepositElementStatus.Tracking;
}
} else if (track.type === "wired") {
updatedTxStatus = DepositElementStatus.Wired;
const payto = parsePaytoUri(depositGroup.wire.payto_uri);
if (!payto) {
throw Error(`unparsable payto: ${depositGroup.wire.payto_uri}`);
}
const fee = await getExchangeWireFee(
wex,
payto.targetType,
exchangeBaseUrl,
track.execution_time,
);
const raw = Amounts.parseOrThrow(track.coin_contribution);
const wireFee = Amounts.parseOrThrow(fee.wireFee);
newWiredCoin = {
value: {
amountRaw: Amounts.stringify(raw),
wireFee: Amounts.stringify(wireFee),
exchangePub: track.exchange_pub,
timestampExecuted: timestampProtocolToDb(track.execution_time),
wireTransferId: track.wtid,
},
id: track.exchange_sig,
};
} else {
updatedTxStatus = DepositElementStatus.DepositPending;
}
}
if (updatedTxStatus !== undefined) {
await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return;
}
if (!dg.statusPerCoin) {
return;
}
if (updatedTxStatus !== undefined) {
dg.statusPerCoin[i] = updatedTxStatus;
}
if (newWiredCoin) {
/**
* FIXME: if there is a new wire information from the exchange
* it should add up to the previous tracking states.
*
* This may loose information by overriding prev state.
*
* And: add checks to integration tests
*/
if (!dg.trackingState) {
dg.trackingState = {};
}
dg.trackingState[newWiredCoin.id] = newWiredCoin.value;
}
await tx.depositGroups.put(dg);
await ctx.updateTransactionMeta(tx);
},
);
}
}
let allWired = true;
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return undefined;
}
if (!dg.statusPerCoin) {
return undefined;
}
const oldTxState = computeDepositTransactionStatus(dg);
for (let i = 0; i < dg.statusPerCoin.length; i++) {
if (dg.statusPerCoin[i] !== DepositElementStatus.Wired) {
allWired = false;
break;
}
}
if (allWired) {
dg.timestampFinished = timestampPreciseToDb(
TalerPreciseTimestamp.now(),
);
dg.operationStatus = DepositOperationStatus.Finished;
await tx.depositGroups.put(dg);
await ctx.updateTransactionMeta(tx);
}
const newTxState = computeDepositTransactionStatus(dg);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
if (allWired) {
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: ctx.transactionId,
});
return TaskRunResult.finished();
} else {
return TaskRunResult.longpollReturnedPending();
}
}
async function processDepositGroupPendingDeposit(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
cancellationToken?: CancellationToken,
): Promise {
logger.info("processing deposit group in pending(deposit)");
const depositGroupId = depositGroup.depositGroupId;
const contractTermsRec = await wex.db.runReadOnlyTx(
{ storeNames: ["contractTerms"] },
async (tx) => {
return tx.contractTerms.get(depositGroup.contractTermsHash);
},
);
if (!contractTermsRec) {
throw Error("contract terms for deposit not found in database");
}
const contractTerms: MerchantContractTerms =
contractTermsRec.contractTermsRaw;
const contractData = extractContractData(
contractTermsRec.contractTermsRaw,
depositGroup.contractTermsHash,
"",
);
const ctx = new DepositTransactionContext(wex, depositGroupId);
// Check for cancellation before expensive operations.
cancellationToken?.throwIfCancelled();
if (!depositGroup.payCoinSelection) {
logger.info("missing coin selection for deposit group, selecting now");
const transitionDone = await wex.db.runReadWriteTx(
{
storeNames: [
"contractTerms",
"exchanges",
"exchangeDetails",
"depositGroups",
"coins",
"coinAvailability",
"coinHistory",
"refreshGroups",
"refreshSessions",
"denominations",
"transactionsMeta",
],
},
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return false;
}
if (dg.statusPerCoin) {
return false;
}
const contractTermsRec = tx.contractTerms.get(
depositGroup.contractTermsHash,
);
if (!contractTermsRec) {
throw Error("contract terms for deposit not found in database");
}
const payCoinSel = await selectPayCoinsInTx(wex, tx, {
restrictExchanges: {
auditors: [],
exchanges: contractData.allowedExchanges,
},
restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
prevPayCoins: [],
});
switch (payCoinSel.type) {
case "success":
logger.info("coin selection success");
break;
case "failure":
logger.info("coin selection failure");
throw TalerError.fromDetail(
TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails:
payCoinSel.insufficientBalanceDetails,
},
);
case "prospective":
logger.info("coin selection prospective");
throw Error("insufficient balance (waiting on pending refresh)");
default:
assertUnreachable(payCoinSel);
}
dg.payCoinSelection = {
coinContributions: payCoinSel.coinSel.coins.map(
(x) => x.contribution,
),
coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
};
dg.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
dg.statusPerCoin = payCoinSel.coinSel.coins.map(
() => DepositElementStatus.DepositPending,
);
await tx.depositGroups.put(dg);
await ctx.updateTransactionMeta(tx);
await spendCoins(wex, tx, {
transactionId: ctx.transactionId,
coinPubs: dg.payCoinSelection.coinPubs,
contributions: dg.payCoinSelection.coinContributions.map((x) =>
Amounts.parseOrThrow(x),
),
refreshReason: RefreshReason.PayDeposit,
});
return true;
},
);
if (transitionDone) {
return TaskRunResult.progress();
} else {
return TaskRunResult.backoff();
}
}
// FIXME: Cache these!
const depositPermissions = await generateDepositPermissions(
wex,
depositGroup.payCoinSelection,
contractData,
);
// Exchanges involved in the deposit
const exchanges: Set = new Set();
for (const dp of depositPermissions) {
exchanges.add(dp.exchange_url);
}
// We need to do one batch per exchange.
for (const exchangeBaseUrl of exchanges.values()) {
const coins: BatchDepositRequestCoin[] = [];
const batchIndexes: number[] = [];
const batchReq: ExchangeBatchDepositRequest = {
coins,
h_contract_terms: depositGroup.contractTermsHash,
merchant_payto_uri: depositGroup.wire.payto_uri,
merchant_pub: contractTerms.merchant_pub,
timestamp: contractTerms.timestamp,
wire_salt: depositGroup.wire.salt,
wire_transfer_deadline: contractTerms.wire_transfer_deadline,
refund_deadline: contractTerms.refund_deadline,
};
for (let i = 0; i < depositPermissions.length; i++) {
const perm = depositPermissions[i];
if (perm.exchange_url != exchangeBaseUrl) {
continue;
}
coins.push({
coin_pub: perm.coin_pub,
coin_sig: perm.coin_sig,
contribution: Amounts.stringify(perm.contribution),
denom_pub_hash: perm.h_denom,
ub_sig: perm.ub_sig,
h_age_commitment: perm.h_age_commitment,
});
batchIndexes.push(i);
}
// Check for cancellation before making network request.
cancellationToken?.throwIfCancelled();
const url = new URL(`batch-deposit`, exchangeBaseUrl);
logger.info(`depositing to ${url.href}`);
logger.trace(`deposit request: ${j2s(batchReq)}`);
const httpResp = await wex.http.fetch(url.href, {
method: "POST",
body: batchReq,
cancellationToken: cancellationToken,
});
switch (httpResp.status) {
case HttpStatusCode.Accepted:
case HttpStatusCode.Ok:
break;
case HttpStatusCode.UnavailableForLegalReasons: {
const kycLegiNeededResp = await readResponseJsonOrThrow(
httpResp,
codecForLegitimizationNeededResponse(),
);
logger.info(
`kyc legitimization needed response: ${j2s(kycLegiNeededResp)}`,
);
return transitionToKycRequired(
wex,
depositGroup,
kycLegiNeededResp.h_payto,
exchangeBaseUrl,
);
}
}
await readSuccessResponseJsonOrThrow(
httpResp,
codecForBatchDepositSuccess(),
);
await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return;
}
if (!dg.statusPerCoin) {
return;
}
for (const batchIndex of batchIndexes) {
const coinStatus = dg.statusPerCoin[batchIndex];
switch (coinStatus) {
case DepositElementStatus.DepositPending:
dg.statusPerCoin[batchIndex] = DepositElementStatus.Tracking;
await tx.depositGroups.put(dg);
}
}
await ctx.updateTransactionMeta(tx);
},
);
}
const transitionInfo = await wex.db.runReadWriteTx(
{ storeNames: ["depositGroups", "transactionsMeta"] },
async (tx) => {
const dg = await tx.depositGroups.get(depositGroupId);
if (!dg) {
return undefined;
}
const oldTxState = computeDepositTransactionStatus(dg);
dg.operationStatus = DepositOperationStatus.PendingTrack;
await tx.depositGroups.put(dg);
await ctx.updateTransactionMeta(tx);
const newTxState = computeDepositTransactionStatus(dg);
return { oldTxState, newTxState };
},
);
notifyTransition(wex, ctx.transactionId, transitionInfo);
return TaskRunResult.progress();
}
/**
* Process a deposit group that is not in its final state yet.
*/
export async function processDepositGroup(
wex: WalletExecutionContext,
depositGroupId: string,
): Promise {
if (!wex.ws.networkAvailable) {
return TaskRunResult.networkRequired();
}
const depositGroup = await wex.db.runReadOnlyTx(
{ storeNames: ["depositGroups"] },
async (tx) => {
return tx.depositGroups.get(depositGroupId);
},
);
if (!depositGroup) {
logger.warn(`deposit group ${depositGroupId} not found`);
return TaskRunResult.finished();
}
switch (depositGroup.operationStatus) {
case DepositOperationStatus.PendingTrack:
return processDepositGroupPendingTrack(wex, depositGroup);
case DepositOperationStatus.PendingAggregateKyc:
case DepositOperationStatus.PendingDepositKyc:
return processDepositGroupPendingKyc(wex, depositGroup);
case DepositOperationStatus.PendingDeposit:
return processDepositGroupPendingDeposit(wex, depositGroup);
case DepositOperationStatus.Aborting:
return processDepositGroupAborting(wex, depositGroup);
}
return TaskRunResult.finished();
}
async function trackDeposit(
wex: WalletExecutionContext,
depositGroup: DepositGroupRecord,
coinPub: string,
exchangeUrl: string,
): Promise {
const wireHash = hashWire(
depositGroup.wire.payto_uri,
depositGroup.wire.salt,
);
const url = new URL(
`deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${coinPub}`,
exchangeUrl,
);
const sigResp = await wex.cryptoApi.signTrackTransaction({
coinPub,
contractTermsHash: depositGroup.contractTermsHash,
merchantPriv: depositGroup.merchantPriv,
merchantPub: depositGroup.merchantPub,
wireHash,
});
url.searchParams.set("merchant_sig", sigResp.sig);
const httpResp = await wex.ws.runLongpollQueueing(
wex,
url.hostname,
async (timeoutMs) => {
url.searchParams.set("timeout_ms", `${timeoutMs}`);
return await wex.http.fetch(url.href, {
method: "GET",
cancellationToken: wex.cancellationToken,
});
},
);
logger.trace(`deposits response status: ${httpResp.status}`);
switch (httpResp.status) {
case HttpStatusCode.Accepted: {
const accepted = await readSuccessResponseJsonOrThrow(
httpResp,
codecForTackTransactionAccepted(),
);
return { type: "accepted", ...accepted };
}
case HttpStatusCode.Ok: {
const wired = await readSuccessResponseJsonOrThrow(
httpResp,
codecForTackTransactionWired(),
);
return { type: "wired", ...wired };
}
default: {
throw Error(
`unexpected response from track-transaction (${httpResp.status})`,
);
}
}
}
/**
* Check if creating a deposit group is possible and calculate
* the associated fees.
*/
export async function checkDepositGroup(
wex: WalletExecutionContext,
req: PrepareDepositRequest,
): Promise {
return await runWithClientCancellation(
wex,
"checkDepositGroup",
req.clientCancellationId,
() => internalCheckDepositGroup(wex, req),
);
}
/**
* Check if creating a deposit group is possible and calculate
* the associated fees.
*/
export async function internalCheckDepositGroup(
wex: WalletExecutionContext,
req: PrepareDepositRequest,
): Promise {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
const currency = Amounts.currencyOf(amount);
const exchangeInfos: ExchangeHandle[] = [];
await wex.db.runReadOnlyTx(
{ storeNames: ["exchangeDetails", "exchanges"] },
async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
if (!details || amount.currency !== details.currency) {
continue;
}
exchangeInfos.push({
master_pub: details.masterPublicKey,
url: e.baseUrl,
});
}
},
);
const now = AbsoluteTime.now();
const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
const contractTerms: MerchantContractTerms = {
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: nowRounded,
merchant_base_url: "",
summary: "",
nonce: "",
wire_transfer_deadline: nowRounded,
order_id: "",
h_wire: "",
pay_deadline: AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
),
merchant: {
name: "(wallet)",
},
merchant_pub: "",
refund_deadline: TalerProtocolTimestamp.zero(),
};
const { h: contractTermsHash } = await wex.cryptoApi.hashString({
str: canonicalJson(contractTerms),
});
const contractData = extractContractData(
contractTerms,
contractTermsHash,
"",
);
const payCoinSel = await selectPayCoins(wex, {
restrictExchanges: {
auditors: [],
exchanges: contractData.allowedExchanges,
},
restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
prevPayCoins: [],
});
let selCoins: SelectedProspectiveCoin[] | undefined = undefined;
switch (payCoinSel.type) {
case "failure":
throw TalerError.fromDetail(
TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
},
);
case "prospective":
selCoins = payCoinSel.result.prospectiveCoins;
break;
case "success":
selCoins = payCoinSel.coinSel.coins;
break;
default:
assertUnreachable(payCoinSel);
}
const totalDepositCost = await getTotalPaymentCost(wex, currency, selCoins);
const effectiveDepositAmount = await getCounterpartyEffectiveDepositAmount(
wex,
p.targetType,
selCoins,
);
const fees = await getTotalFeesForDepositAmount(
wex,
p.targetType,
amount,
selCoins,
);
return {
totalDepositCost: Amounts.stringify(totalDepositCost),
effectiveDepositAmount: Amounts.stringify(effectiveDepositAmount),
fees,
};
}
export function generateDepositGroupTxId(): string {
const depositGroupId = encodeCrock(getRandomBytes(32));
return constructTransactionIdentifier({
tag: TransactionType.Deposit,
depositGroupId: depositGroupId,
});
}
export async function createDepositGroup(
wex: WalletExecutionContext,
req: CreateDepositGroupRequest,
): Promise {
const p = parsePaytoUri(req.depositPaytoUri);
if (!p) {
throw Error("invalid payto URI");
}
const amount = Amounts.parseOrThrow(req.amount);
const currency = amount.currency;
const exchangeInfos: { url: string; master_pub: string }[] = [];
await wex.db.runReadOnlyTx(
{ storeNames: ["exchanges", "exchangeDetails"] },
async (tx) => {
const allExchanges = await tx.exchanges.iter().toArray();
for (const e of allExchanges) {
const details = await getExchangeWireDetailsInTx(tx, e.baseUrl);
if (!details || amount.currency !== details.currency) {
continue;
}
exchangeInfos.push({
master_pub: details.masterPublicKey,
url: e.baseUrl,
});
}
},
);
const now = AbsoluteTime.now();
const wireDeadline = AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(now, Duration.fromSpec({ minutes: 5 })),
);
const nowRounded = AbsoluteTime.toProtocolTimestamp(now);
const noncePair = await wex.cryptoApi.createEddsaKeypair({});
const merchantPair = await wex.cryptoApi.createEddsaKeypair({});
const wireSalt = encodeCrock(getRandomBytes(16));
const wireHash = hashWire(req.depositPaytoUri, wireSalt);
const contractTerms: MerchantContractTerms = {
exchanges: exchangeInfos,
amount: req.amount,
max_fee: Amounts.stringify(amount),
wire_method: p.targetType,
timestamp: nowRounded,
merchant_base_url: "",
summary: "",
nonce: noncePair.pub,
wire_transfer_deadline: wireDeadline,
order_id: "",
h_wire: wireHash,
pay_deadline: AbsoluteTime.toProtocolTimestamp(
AbsoluteTime.addDuration(now, Duration.fromSpec({ hours: 1 })),
),
merchant: {
name: "(wallet)",
},
merchant_pub: merchantPair.pub,
refund_deadline: TalerProtocolTimestamp.zero(),
};
const { h: contractTermsHash } = await wex.cryptoApi.hashString({
str: canonicalJson(contractTerms),
});
const contractData = extractContractData(
contractTerms,
contractTermsHash,
"",
);
const payCoinSel = await selectPayCoins(wex, {
restrictExchanges: {
auditors: [],
exchanges: contractData.allowedExchanges,
},
restrictWireMethod: contractData.wireMethod,
contractTermsAmount: Amounts.parseOrThrow(contractData.amount),
depositFeeLimit: Amounts.parseOrThrow(contractData.maxDepositFee),
prevPayCoins: [],
});
let coins: SelectedProspectiveCoin[] | undefined = undefined;
switch (payCoinSel.type) {
case "success":
coins = payCoinSel.coinSel.coins;
break;
case "failure":
throw TalerError.fromDetail(
TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE,
{
insufficientBalanceDetails: payCoinSel.insufficientBalanceDetails,
},
);
case "prospective":
coins = payCoinSel.result.prospectiveCoins;
break;
default:
assertUnreachable(payCoinSel);
}
const totalDepositCost = await getTotalPaymentCost(wex, currency, coins);
let depositGroupId: string;
if (req.transactionId) {
const txId = parseTransactionIdentifier(req.transactionId);
if (!txId || txId.tag !== TransactionType.Deposit) {
throw Error("invalid transaction ID");
}
depositGroupId = txId.depositGroupId;
} else {
depositGroupId = encodeCrock(getRandomBytes(32));
}
const infoPerExchange: Record = {};
for (let i = 0; i < coins.length; i++) {
let depPerExchange = infoPerExchange[coins[i].exchangeBaseUrl];
if (!depPerExchange) {
infoPerExchange[coins[i].exchangeBaseUrl] = depPerExchange = {
amountEffective: Amounts.stringify(
Amounts.zeroOfAmount(totalDepositCost),
),
};
}
const contrib = coins[i].contribution;
depPerExchange.amountEffective = Amounts.stringify(
Amounts.add(depPerExchange.amountEffective, contrib).amount,
);
}
const counterpartyEffectiveDepositAmount =
await getCounterpartyEffectiveDepositAmount(wex, p.targetType, coins);
const depositGroup: DepositGroupRecord = {
contractTermsHash,
depositGroupId,
currency: Amounts.currencyOf(totalDepositCost),
amount: contractData.amount,
noncePriv: noncePair.priv,
noncePub: noncePair.pub,
timestampCreated: timestampPreciseToDb(
AbsoluteTime.toPreciseTimestamp(now),
),
timestampFinished: undefined,
statusPerCoin: undefined,
payCoinSelection: undefined,
payCoinSelectionUid: undefined,
merchantPriv: merchantPair.priv,
merchantPub: merchantPair.pub,
totalPayCost: Amounts.stringify(totalDepositCost),
counterpartyEffectiveDepositAmount: Amounts.stringify(
counterpartyEffectiveDepositAmount,
),
wireTransferDeadline: timestampProtocolToDb(
contractTerms.wire_transfer_deadline,
),
wire: {
payto_uri: req.depositPaytoUri,
salt: wireSalt,
},
operationStatus: DepositOperationStatus.PendingDeposit,
infoPerExchange,
};
if (payCoinSel.type === "success") {
depositGroup.payCoinSelection = {
coinContributions: payCoinSel.coinSel.coins.map((x) => x.contribution),
coinPubs: payCoinSel.coinSel.coins.map((x) => x.coinPub),
};
depositGroup.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
depositGroup.statusPerCoin = payCoinSel.coinSel.coins.map(
() => DepositElementStatus.DepositPending,
);
}
const ctx = new DepositTransactionContext(wex, depositGroupId);
const transactionId = ctx.transactionId;
const newTxState = await wex.db.runReadWriteTx(
{
storeNames: [
"coinAvailability",
"coinHistory",
"coins",
"contractTerms",
"denominations",
"depositGroups",
"recoupGroups",
"refreshGroups",
"refreshSessions",
"transactionsMeta",
],
},
async (tx) => {
if (depositGroup.payCoinSelection) {
await spendCoins(wex, tx, {
transactionId,
coinPubs: depositGroup.payCoinSelection.coinPubs,
contributions: depositGroup.payCoinSelection.coinContributions.map(
(x) => Amounts.parseOrThrow(x),
),
refreshReason: RefreshReason.PayDeposit,
});
}
await tx.depositGroups.put(depositGroup);
await tx.contractTerms.put({
contractTermsRaw: contractTerms,
h: contractTermsHash,
});
await ctx.updateTransactionMeta(tx);
return computeDepositTransactionStatus(depositGroup);
},
);
wex.ws.notify({
type: NotificationType.TransactionStateTransition,
transactionId,
oldTxState: {
major: TransactionMajorState.None,
},
newTxState,
});
wex.ws.notify({
type: NotificationType.BalanceChange,
hintTransactionId: transactionId,
});
wex.taskScheduler.startShepherdTask(ctx.taskId);
return {
depositGroupId,
transactionId,
};
}
/**
* Get the amount that will be deposited on the users bank
* account after depositing, not considering aggregation.
*/
async function getCounterpartyEffectiveDepositAmount(
wex: WalletExecutionContext,
wireType: string,
pcs: SelectedProspectiveCoin[],
): Promise {
const amt: AmountJson[] = [];
const fees: AmountJson[] = [];
const exchangeSet: Set = new Set();
await wex.db.runReadOnlyTx(
{ storeNames: ["coins", "denominations", "exchangeDetails", "exchanges"] },
async (tx) => {
for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
wex,
tx,
pcs[i].exchangeBaseUrl,
pcs[i].denomPubHash,
);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
amt.push(Amounts.parseOrThrow(pcs[i].contribution));
fees.push(Amounts.parseOrThrow(denom.feeDeposit));
exchangeSet.add(pcs[i].exchangeBaseUrl);
}
for (const exchangeUrl of exchangeSet.values()) {
const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
exchangeUrl,
);
if (!exchangeDetails) {
continue;
}
// FIXME/NOTE: the line below _likely_ throws exception
// about "find method not found on undefined" when the wireType
// is not supported by the Exchange.
const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => {
return AbsoluteTime.isBetween(
AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(x.startStamp),
AbsoluteTime.fromProtocolTimestamp(x.endStamp),
);
})?.wireFee;
if (fee) {
fees.push(Amounts.parseOrThrow(fee));
}
}
},
);
return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount;
}
/**
* Get the fee amount that will be charged when trying to deposit the
* specified amount using the selected coins and the wire method.
*/
async function getTotalFeesForDepositAmount(
wex: WalletExecutionContext,
wireType: string,
total: AmountJson,
pcs: SelectedProspectiveCoin[],
): Promise {
const wireFee: AmountJson[] = [];
const coinFee: AmountJson[] = [];
const refreshFee: AmountJson[] = [];
const exchangeSet: Set = new Set();
await wex.db.runReadOnlyTx(
{ storeNames: ["coins", "denominations", "exchanges", "exchangeDetails"] },
async (tx) => {
for (let i = 0; i < pcs.length; i++) {
const denom = await getDenomInfo(
wex,
tx,
pcs[i].exchangeBaseUrl,
pcs[i].denomPubHash,
);
if (!denom) {
throw Error("can't find denomination to calculate deposit amount");
}
coinFee.push(Amounts.parseOrThrow(denom.feeDeposit));
exchangeSet.add(pcs[i].exchangeBaseUrl);
const amountLeft = Amounts.sub(denom.value, pcs[i].contribution).amount;
const refreshCost = await getTotalRefreshCost(
wex,
tx,
denom,
amountLeft,
);
refreshFee.push(refreshCost);
}
for (const exchangeUrl of exchangeSet.values()) {
const exchangeDetails = await getExchangeWireDetailsInTx(
tx,
exchangeUrl,
);
if (!exchangeDetails) {
continue;
}
const fee = exchangeDetails.wireInfo.feesForType[wireType]?.find(
(x) => {
return AbsoluteTime.isBetween(
AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(x.startStamp),
AbsoluteTime.fromProtocolTimestamp(x.endStamp),
);
},
)?.wireFee;
if (fee) {
wireFee.push(Amounts.parseOrThrow(fee));
}
}
},
);
return {
coin: Amounts.stringify(Amounts.sumOrZero(total.currency, coinFee).amount),
wire: Amounts.stringify(Amounts.sumOrZero(total.currency, wireFee).amount),
refresh: Amounts.stringify(
Amounts.sumOrZero(total.currency, refreshFee).amount,
),
};
}