diff options
| author | Sebastian <sebasjm@gmail.com> | 2023-06-13 16:46:16 -0300 | 
|---|---|---|
| committer | Sebastian <sebasjm@gmail.com> | 2023-06-13 16:46:16 -0300 | 
| commit | 8b74bda065e1f2e01b322662dce3a002aa0fb20b (patch) | |
| tree | 8865b61d2ed35430a1171dd041890f36e659f2a0 /packages/taler-wallet-core/src/util | |
| parent | 671342818fb79123f231cfcbaf8251f1672effd1 (diff) | |
get operation plan impl, no test
Diffstat (limited to 'packages/taler-wallet-core/src/util')
| -rw-r--r-- | packages/taler-wallet-core/src/util/coinSelection.ts | 512 | 
1 files changed, 505 insertions, 7 deletions
| diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index f4066bf51..8fd09ea2b 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -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, +      }; +    }); +} | 
