get operation plan impl, no test

This commit is contained in:
Sebastian 2023-06-13 16:46:16 -03:00
parent 671342818f
commit 8b74bda065
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
4 changed files with 534 additions and 14 deletions

View File

@ -52,7 +52,11 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import { checkDbInvariant } from "../util/invariants.js";
import { getPeerPaymentBalanceDetailsInTx } from "./balance.js";
import { getTotalRefreshCost } from "./refresh.js";
import { OperationAttemptLongpollResult, OperationAttemptResult, OperationAttemptResultType } from "../util/retries.js";
import {
OperationAttemptLongpollResult,
OperationAttemptResult,
OperationAttemptResultType,
} from "../util/retries.js";
const logger = new Logger("operations/peer-to-peer.ts");
@ -113,6 +117,12 @@ export type SelectPeerCoinsResult =
insufficientBalanceDetails: PayPeerInsufficientBalanceDetails;
};
/**
* Get information about the coin selected for signatures
* @param ws
* @param csel
* @returns
*/
export async function queryCoinInfosForSelection(
ws: InternalWalletState,
csel: PeerPushPaymentCoinSelection,

View File

@ -30,29 +30,29 @@ import {
AgeRestriction,
AmountJson,
Amounts,
AmountString,
CoinStatus,
DenominationInfo,
DenominationPubKey,
DenomSelectionState,
ForcedCoinSel,
ForcedDenomSel,
GetPlanForOperationRequest,
GetPlanForOperationResponse,
j2s,
Logger,
parsePaytoUri,
PayCoinSelection,
PayMerchantInsufficientBalanceDetails,
strcmp,
TransactionType,
} from "@gnu-taler/taler-util";
import {
AllowedAuditorInfo,
AllowedExchangeInfo,
DenominationRecord,
} from "../db.js";
import {
getExchangeDetails,
isWithdrawableDenom,
WalletConfig,
} from "../index.js";
import { getExchangeDetails, isWithdrawableDenom } from "../index.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { getMerchantPaymentBalanceDetails } from "../operations/balance.js";
import { checkDbInvariant, checkLogicInvariant } from "./invariants.js";
@ -150,7 +150,7 @@ export interface CoinSelectionTally {
/**
* Account for the fees of spending a coin.
*/
export function tallyFees(
function tallyFees(
tally: Readonly<CoinSelectionTally>,
wireFeesPerExchange: Record<string, AmountJson>,
wireFeeAmortization: number,
@ -542,7 +542,7 @@ export type AvailableDenom = DenominationInfo & {
numAvailable: number;
};
export async function selectCandidates(
async function selectCandidates(
ws: InternalWalletState,
req: SelectPayCoinRequestNg,
): Promise<[AvailableDenom[], Record<string, AmountJson>]> {
@ -789,3 +789,501 @@ export function selectForcedWithdrawalDenominations(
totalWithdrawCost: Amounts.stringify(totalWithdrawCost),
};
}
/**
* simulate a coin selection and return the amount
* that will effectively change the wallet balance and
* the raw amount of the operation
*
* @param ws
* @param br
* @returns
*/
export async function getPlanForOperation(
ws: InternalWalletState,
req: GetPlanForOperationRequest,
): Promise<GetPlanForOperationResponse> {
const amount = Amounts.parseOrThrow(req.instructedAmount);
switch (req.type) {
case TransactionType.Withdrawal: {
const availableCoins = await getAvailableCoins(
ws,
"credit",
amount.currency,
false,
false,
undefined,
undefined,
undefined,
);
const usableCoins = selectCoinForOperation(
"credit",
amount,
req.mode === "effective" ? "net" : "gross",
availableCoins.denoms,
);
return getAmountsWithFee(
"credit",
usableCoins.totalValue,
usableCoins.totalContribution,
usableCoins,
);
}
case TransactionType.Deposit: {
const payto = parsePaytoUri(req.account)!;
const availableCoins = await getAvailableCoins(
ws,
"debit",
amount.currency,
true,
false,
undefined,
[payto.targetType],
undefined,
);
//FIXME: just doing for 1 exchange now
//assuming that the wallet has one exchange and all the coins available
//are from that exchange
const wireFee = Object.entries(availableCoins.wfPerExchange)[0][1][
payto.targetType
];
let usableCoins;
if (req.mode === "effective") {
usableCoins = selectCoinForOperation(
"debit",
amount,
"gross",
availableCoins.denoms,
);
usableCoins.totalContribution = Amounts.stringify(
Amounts.sub(usableCoins.totalContribution, wireFee).amount,
);
} else {
const adjustedAmount = Amounts.add(amount, wireFee).amount;
usableCoins = selectCoinForOperation(
"debit",
adjustedAmount,
// amount,
"net",
availableCoins.denoms,
);
usableCoins.totalContribution = Amounts.stringify(
Amounts.sub(usableCoins.totalContribution, wireFee).amount,
);
}
return getAmountsWithFee(
"debit",
usableCoins.totalValue,
usableCoins.totalContribution,
usableCoins,
);
}
default: {
throw Error("operation not supported");
}
}
}
function getAmountsWithFee(
op: "debit" | "credit",
value: AmountString,
contribution: AmountString,
details: any,
): GetPlanForOperationResponse {
return {
rawAmount: op === "credit" ? value : contribution,
effectiveAmount: op === "credit" ? contribution : value,
details,
};
}
/**
*
* @param op defined which fee are we taking into consideration: deposits or withdraw
* @param limit the total amount limit of the operation
* @param mode if the total amount is includes the fees or just the contribution
* @param denoms list of available denomination for the operation
* @returns
*/
function selectCoinForOperation(
op: "debit" | "credit",
limit: AmountJson,
mode: "net" | "gross",
denoms: AvailableDenom[],
): SelectedCoins {
const result: SelectedCoins = {
totalValue: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
totalWithdrawalFee: Amounts.stringify(
Amounts.zeroOfCurrency(limit.currency),
),
totalDepositFee: Amounts.stringify(Amounts.zeroOfCurrency(limit.currency)),
totalContribution: Amounts.stringify(
Amounts.zeroOfCurrency(limit.currency),
),
coins: [],
};
if (!denoms.length) return result;
/**
* We can make this faster. We should prevent sorting and
* keep the information ready for multiple calls since this
* function is expected to work on embedded devices and
* create a response on key press
*/
//rank coins
denoms.sort(
op === "credit"
? denomsByDescendingWithdrawContribution
: denomsByDescendingDepositContribution,
);
//take coins in order until amount
let selectedCoinsAreEnough = false;
let denomIdx = 0;
iterateDenoms: while (denomIdx < denoms.length) {
const cur = denoms[denomIdx];
// for (const cur of denoms) {
let total = op === "credit" ? Number.MAX_SAFE_INTEGER : cur.numAvailable;
const opFee = op === "credit" ? cur.feeWithdraw : cur.feeDeposit;
const contribution = Amounts.sub(cur.value, opFee).amount;
if (Amounts.isZero(contribution)) {
// 0 contribution denoms should be the last
break iterateDenoms;
}
iterateCoins: while (total > 0) {
const nextValue = Amounts.add(result.totalValue, cur.value).amount;
const nextContribution = Amounts.add(
result.totalContribution,
contribution,
).amount;
const progress = mode === "gross" ? nextValue : nextContribution;
if (Amounts.cmp(progress, limit) === 1) {
//the current coin is more than we need, try next denom
break iterateCoins;
}
result.totalValue = Amounts.stringify(nextValue);
result.totalContribution = Amounts.stringify(nextContribution);
result.totalDepositFee = Amounts.stringify(
Amounts.add(result.totalDepositFee, cur.feeDeposit).amount,
);
result.totalWithdrawalFee = Amounts.stringify(
Amounts.add(result.totalWithdrawalFee, cur.feeWithdraw).amount,
);
result.coins.push(cur.denomPubHash);
if (Amounts.cmp(progress, limit) === 0) {
selectedCoinsAreEnough = true;
// we have just enough coins, complete
break iterateDenoms;
}
//go next coin
total--;
}
//go next denom
denomIdx++;
}
if (selectedCoinsAreEnough) {
// we made it
return result;
}
if (op === "credit") {
//doing withdraw there is no way to cover the gap
return result;
}
//tried all the coins but there is a gap
//doing deposit we can try refreshing coins
const total = mode === "gross" ? result.totalValue : result.totalContribution;
const gap = Amounts.sub(limit, total).amount;
//about recursive calls
//the only way to get here is by doing a deposit (that will do a refresh)
//and now we are calculating fee for credit (which does not need to calculate refresh)
let refreshIdx = 0;
let choice: RefreshChoice | undefined = undefined;
refreshIteration: while (refreshIdx < denoms.length) {
const d = denoms[refreshIdx];
const denomContribution =
mode === "gross"
? Amounts.sub(d.value, d.feeRefresh).amount
: Amounts.sub(d.value, d.feeDeposit, d.feeRefresh).amount;
const changeAfterDeposit = Amounts.sub(denomContribution, gap).amount;
if (Amounts.isZero(changeAfterDeposit)) {
//the rest of the coins are very small
break refreshIteration;
}
const changeCost = selectCoinForOperation(
"credit",
changeAfterDeposit,
mode,
denoms,
);
const totalFee = Amounts.add(
d.feeDeposit,
d.feeRefresh,
changeCost.totalWithdrawalFee,
).amount;
if (!choice || Amounts.cmp(totalFee, choice.totalFee) === -1) {
//found cheaper change
choice = {
gap: Amounts.stringify(gap),
totalFee: Amounts.stringify(totalFee),
selected: d.denomPubHash,
totalValue: d.value,
totalRefreshFee: Amounts.stringify(d.feeRefresh),
totalDepositFee: d.feeDeposit,
totalChangeValue: Amounts.stringify(changeCost.totalValue),
totalChangeContribution: Amounts.stringify(
changeCost.totalContribution,
),
totalChangeWithdrawalFee: Amounts.stringify(
changeCost.totalWithdrawalFee,
),
change: changeCost.coins,
};
}
refreshIdx++;
}
if (choice) {
if (mode === "gross") {
result.totalValue = Amounts.stringify(
Amounts.add(result.totalValue, gap).amount,
);
result.totalContribution = Amounts.stringify(
Amounts.add(result.totalContribution, gap).amount,
);
result.totalContribution = Amounts.stringify(
Amounts.sub(result.totalContribution, choice.totalFee).amount,
);
} else {
result.totalContribution = Amounts.stringify(
Amounts.add(result.totalContribution, gap).amount,
);
result.totalValue = Amounts.stringify(
Amounts.add(result.totalValue, gap, choice.totalFee).amount,
);
}
}
// console.log("gap", Amounts.stringify(limit), Amounts.stringify(gap), choice);
result.refresh = choice;
return result;
}
function denomsByDescendingDepositContribution(
d1: AvailableDenom,
d2: AvailableDenom,
) {
const contrib1 = Amounts.sub(d1.value, d1.feeDeposit).amount;
const contrib2 = Amounts.sub(d2.value, d2.feeDeposit).amount;
return (
Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash)
);
}
function denomsByDescendingWithdrawContribution(
d1: AvailableDenom,
d2: AvailableDenom,
) {
const contrib1 = Amounts.sub(d1.value, d1.feeWithdraw).amount;
const contrib2 = Amounts.sub(d2.value, d2.feeWithdraw).amount;
return (
Amounts.cmp(contrib2, contrib1) || strcmp(d1.denomPubHash, d2.denomPubHash)
);
}
interface RefreshChoice {
gap: AmountString;
totalFee: AmountString;
selected: string;
totalValue: AmountString;
totalDepositFee: AmountString;
totalRefreshFee: AmountString;
totalChangeValue: AmountString;
totalChangeContribution: AmountString;
totalChangeWithdrawalFee: AmountString;
change: string[];
}
interface SelectedCoins {
totalValue: AmountString;
totalContribution: AmountString;
totalWithdrawalFee: AmountString;
totalDepositFee: AmountString;
coins: string[];
refresh?: RefreshChoice;
}
/**
* Get all the denoms that can be used for a operation that is limited
* by the following restrictions.
* This function is costly (by the database access) but with high chances
* of being cached
*/
async function getAvailableCoins(
ws: InternalWalletState,
op: "credit" | "debit",
currency: string,
shouldCalculateWireFee: boolean,
shouldCalculatePurseFee: boolean,
exchangeFilter: string[] | undefined,
wireMethodFilter: string[] | undefined,
ageRestrictedFilter: number | undefined,
) {
return await ws.db
.mktx((x) => [
x.exchanges,
x.exchangeDetails,
x.denominations,
x.coinAvailability,
])
.runReadOnly(async (tx) => {
const denoms: AvailableDenom[] = [];
const wfPerExchange: Record<string, Record<string, AmountJson>> = {};
const pfPerExchange: Record<string, AmountJson> = {};
const databaseExchanges = await tx.exchanges.iter().toArray();
const exchanges =
exchangeFilter === undefined
? databaseExchanges.map((e) => e.baseUrl)
: exchangeFilter;
for (const exchangeBaseUrl of exchanges) {
const exchangeDetails = await getExchangeDetails(tx, exchangeBaseUrl);
// 1.- exchange has same currency
if (exchangeDetails?.currency !== currency) {
continue;
}
const wireMethodFee: Record<string, AmountJson> = {};
// 2.- exchange supports wire method
if (shouldCalculateWireFee) {
for (const acc of exchangeDetails.wireInfo.accounts) {
const pp = parsePaytoUri(acc.payto_uri);
checkLogicInvariant(!!pp);
// also check that wire method is supported now
if (wireMethodFilter !== undefined) {
if (wireMethodFilter.indexOf(pp.targetType) === -1) {
continue;
}
}
const wireFeeStr = exchangeDetails.wireInfo.feesForType[
pp.targetType
]?.find((x) => {
return AbsoluteTime.isBetween(
AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(x.startStamp),
AbsoluteTime.fromProtocolTimestamp(x.endStamp),
);
})?.wireFee;
if (wireFeeStr) {
wireMethodFee[pp.targetType] = Amounts.parseOrThrow(wireFeeStr);
}
break;
}
if (Object.keys(wireMethodFee).length === 0) {
throw Error(
`exchange ${exchangeBaseUrl} doesn't have wire fee defined for this period`,
);
}
}
wfPerExchange[exchangeBaseUrl] = wireMethodFee;
// 3.- exchange supports wire method
if (shouldCalculatePurseFee) {
const purseFeeFound = exchangeDetails.globalFees.find((x) => {
return AbsoluteTime.isBetween(
AbsoluteTime.now(),
AbsoluteTime.fromProtocolTimestamp(x.startDate),
AbsoluteTime.fromProtocolTimestamp(x.endDate),
);
})?.purseFee;
if (!purseFeeFound) {
throw Error(
`exchange ${exchangeBaseUrl} doesn't have purse fee defined for this period`,
);
}
const purseFee = Amounts.parseOrThrow(purseFeeFound);
pfPerExchange[exchangeBaseUrl] = purseFee;
}
//4.- filter coins restricted by age
if (op === "credit") {
const ds = await tx.denominations.indexes.byExchangeBaseUrl.getAll(
exchangeBaseUrl,
);
for (const denom of ds) {
denoms.push({
...DenominationRecord.toDenomInfo(denom),
numAvailable: Number.MAX_SAFE_INTEGER,
maxAge: AgeRestriction.AGE_UNRESTRICTED,
});
}
} else {
const ageLower = !ageRestrictedFilter ? 0 : ageRestrictedFilter;
const ageUpper = AgeRestriction.AGE_UNRESTRICTED;
const myExchangeCoins =
await tx.coinAvailability.indexes.byExchangeAgeAvailability.getAll(
GlobalIDB.KeyRange.bound(
[exchangeDetails.exchangeBaseUrl, ageLower, 1],
[
exchangeDetails.exchangeBaseUrl,
ageUpper,
Number.MAX_SAFE_INTEGER,
],
),
);
//5.- save denoms with how many coins are available
// FIXME: Check that the individual denomination is audited!
// FIXME: Should we exclude denominations that are
// not spendable anymore?
for (const coinAvail of myExchangeCoins) {
const denom = await tx.denominations.get([
coinAvail.exchangeBaseUrl,
coinAvail.denomPubHash,
]);
checkDbInvariant(!!denom);
if (denom.isRevoked || !denom.isOffered) {
continue;
}
denoms.push({
...DenominationRecord.toDenomInfo(denom),
numAvailable: coinAvail.freshCoinCount ?? 0,
maxAge: coinAvail.maxAge,
});
}
}
}
return {
denoms,
wfPerExchange,
pfPerExchange,
};
});
}

View File

@ -58,6 +58,8 @@ import {
GetContractTermsDetailsRequest,
GetExchangeTosRequest,
GetExchangeTosResult,
GetPlanForOperationRequest,
GetPlanForOperationResponse,
GetWithdrawalDetailsForAmountRequest,
GetWithdrawalDetailsForUriRequest,
InitRequest,
@ -143,6 +145,7 @@ export enum WalletApiOperation {
AcceptManualWithdrawal = "acceptManualWithdrawal",
GetBalances = "getBalances",
GetBalanceDetail = "getBalanceDetail",
GetPlanForOperation = "getPlanForOperation",
GetUserAttentionRequests = "getUserAttentionRequests",
GetUserAttentionUnreadCount = "getUserAttentionUnreadCount",
MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
@ -275,6 +278,12 @@ export type GetBalancesDetailOp = {
response: MerchantPaymentBalanceDetails;
};
export type GetPlanForOperationOp = {
op: WalletApiOperation.GetPlanForOperation;
request: GetPlanForOperationRequest;
response: GetPlanForOperationResponse;
};
// group: Managing Transactions
/**
@ -940,6 +949,7 @@ export type WalletOperations = {
[WalletApiOperation.SuspendTransaction]: SuspendTransactionOp;
[WalletApiOperation.ResumeTransaction]: ResumeTransactionOp;
[WalletApiOperation.GetBalances]: GetBalancesOp;
[WalletApiOperation.GetPlanForOperation]: GetPlanForOperationOp;
[WalletApiOperation.GetBalanceDetail]: GetBalancesDetailOp;
[WalletApiOperation.GetTransactions]: GetTransactionsOp;
[WalletApiOperation.TestingGetSampleTransactions]: TestingGetSampleTransactionsOp;

View File

@ -75,6 +75,7 @@ import {
codecForGetBalanceDetailRequest,
codecForGetContractTermsDetails,
codecForGetExchangeTosRequest,
codecForGetPlanForOperationRequest,
codecForGetWithdrawalDetailsForAmountRequest,
codecForGetWithdrawalDetailsForUri,
codecForImportDbRequest,
@ -218,9 +219,7 @@ import {
processPeerPushDebit,
} from "./operations/pay-peer-push-debit.js";
import { getPendingOperations } from "./operations/pending.js";
import {
createRecoupGroup, processRecoupGroup,
} from "./operations/recoup.js";
import { createRecoupGroup, processRecoupGroup } from "./operations/recoup.js";
import {
autoRefresh,
createRefreshGroup,
@ -283,6 +282,7 @@ import {
WalletCoreApiClient,
WalletCoreResponseType,
} from "./wallet-api-types.js";
import { getPlanForOperation } from "./util/coinSelection.js";
const logger = new Logger("wallet.ts");
@ -331,9 +331,7 @@ async function callOperationHandler(
/**
* Process pending operations.
*/
export async function runPending(
ws: InternalWalletState,
): Promise<void> {
export async function runPending(ws: InternalWalletState): Promise<void> {
const pendingOpsResponse = await getPendingOperations(ws);
for (const p of pendingOpsResponse.pendingOperations) {
if (!AbsoluteTime.isExpired(p.timestampDue)) {
@ -1336,6 +1334,10 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await loadBackupRecovery(ws, req);
return {};
}
case WalletApiOperation.GetPlanForOperation: {
const req = codecForGetPlanForOperationRequest().decode(payload);
return await getPlanForOperation(ws, req);
}
case WalletApiOperation.GetBackupInfo: {
const resp = await getBackupInfo(ws);
return resp;