diff options
author | Florian Dold <florian@dold.me> | 2021-01-18 23:35:41 +0100 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2021-01-18 23:35:41 +0100 |
commit | 5f3c02d31a223add55a32b20f4a289210cbb4f15 (patch) | |
tree | d91ded55692aea1294c0565328515f120559ab6a /packages/taler-wallet-core | |
parent | f884193b1adf0861f710c6ab1bb94ea2073ade65 (diff) |
implement deposits
Diffstat (limited to 'packages/taler-wallet-core')
16 files changed, 836 insertions, 78 deletions
diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts index ef149823c..d7eddd699 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts @@ -43,6 +43,7 @@ import { DerivedTipPlanchet, DeriveRefreshSessionRequest, DeriveTipRequest, + SignTrackTransactionRequest, } from "../../types/cryptoTypes"; const logger = new Logger("cryptoApi.ts"); @@ -326,6 +327,10 @@ export class CryptoApi { return this.doRpc<DerivedTipPlanchet>("createTipPlanchet", 1, req); } + signTrackTransaction(req: SignTrackTransactionRequest): Promise<string> { + return this.doRpc<string>("signTrackTransaction", 1, req); + } + hashString(str: string): Promise<string> { return this.doRpc<string>("hashString", 1, str); } diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts index 1f44d6277..87fad8634 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts @@ -72,11 +72,13 @@ import { DerivedTipPlanchet, DeriveRefreshSessionRequest, DeriveTipRequest, + SignTrackTransactionRequest, } from "../../types/cryptoTypes"; const logger = new Logger("cryptoImplementation.ts"); enum SignaturePurpose { + MERCHANT_TRACK_TRANSACTION = 1103, WALLET_RESERVE_WITHDRAW = 1200, WALLET_COIN_DEPOSIT = 1201, MASTER_DENOMINATION_KEY_VALIDITY = 1025, @@ -211,6 +213,16 @@ export class CryptoImplementation { return tipPlanchet; } + signTrackTransaction(req: SignTrackTransactionRequest): string { + const p = buildSigPS(SignaturePurpose.MERCHANT_TRACK_TRANSACTION) + .put(decodeCrock(req.contractTermsHash)) + .put(decodeCrock(req.wireHash)) + .put(decodeCrock(req.merchantPub)) + .put(decodeCrock(req.coinPub)) + .build(); + return encodeCrock(eddsaSign(p, decodeCrock(req.merchantPriv))); + } + /** * Create and sign a message to recoup a coin. */ diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts new file mode 100644 index 000000000..50921a170 --- /dev/null +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -0,0 +1,420 @@ +/* + This file is part of GNU Taler + (C) 2021 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, + CreateDepositGroupRequest, + guardOperationException, + Logger, + NotificationType, + TalerErrorDetails, +} from ".."; +import { kdf } from "../crypto/primitives/kdf"; +import { + encodeCrock, + getRandomBytes, + stringToBytes, +} from "../crypto/talerCrypto"; +import { DepositGroupRecord, Stores } from "../types/dbTypes"; +import { ContractTerms } from "../types/talerTypes"; +import { CreateDepositGroupResponse, TrackDepositGroupRequest, TrackDepositGroupResponse } from "../types/walletTypes"; +import { + buildCodecForObject, + Codec, + codecForString, + codecOptional, +} from "../util/codec"; +import { canonicalJson } from "../util/helpers"; +import { readSuccessResponseJsonOrThrow } from "../util/http"; +import { parsePaytoUri } from "../util/payto"; +import { initRetryInfo, updateRetryInfoTimeout } from "../util/retries"; +import { + codecForTimestamp, + durationFromSpec, + getTimestampNow, + Timestamp, + timestampAddDuration, + timestampTruncateToSecond, +} from "../util/time"; +import { URL } from "../util/url"; +import { + applyCoinSpend, + extractContractData, + generateDepositPermissions, + getCoinsForPayment, + getEffectiveDepositAmount, + getTotalPaymentCost, +} from "./pay"; +import { InternalWalletState } from "./state"; + +/** + * Logger. + */ +const logger = new Logger("deposits.ts"); + +interface DepositSuccess { + // Optional base URL of the exchange for looking up wire transfers + // associated with this transaction. If not given, + // the base URL is the same as the one used for this request. + // Can be used if the base URL for /transactions/ differs from that + // for /coins/, i.e. for load balancing. Clients SHOULD + // respect the transaction_base_url if provided. Any HTTP server + // belonging to an exchange MUST generate a 307 or 308 redirection + // to the correct base URL should a client uses the wrong base + // URL, or if the base URL has changed since the deposit. + transaction_base_url?: string; + + // timestamp when the deposit was received by the exchange. + exchange_timestamp: Timestamp; + + // the EdDSA signature of TALER_DepositConfirmationPS using a current + // signing key of the exchange affirming the successful + // deposit and that the exchange will transfer the funds after the refund + // deadline, or as soon as possible if the refund deadline is zero. + exchange_sig: string; + + // public EdDSA key of the exchange that was used to + // generate the signature. + // Should match one of the exchange's signing keys from /keys. It is given + // explicitly as the client might otherwise be confused by clock skew as to + // which signing key was used. + exchange_pub: string; +} + +const codecForDepositSuccess = (): Codec<DepositSuccess> => + buildCodecForObject<DepositSuccess>() + .property("exchange_pub", codecForString()) + .property("exchange_sig", codecForString()) + .property("exchange_timestamp", codecForTimestamp) + .property("transaction_base_url", codecOptional(codecForString())) + .build("DepositSuccess"); + +function hashWire(paytoUri: string, salt: string): string { + const r = kdf( + 64, + stringToBytes(paytoUri + "\0"), + stringToBytes(salt + "\0"), + stringToBytes("merchant-wire-signature"), + ); + return encodeCrock(r); +} + +async function resetDepositGroupRetry( + ws: InternalWalletState, + depositGroupId: string, +): Promise<void> { + await ws.db.mutate(Stores.depositGroups, depositGroupId, (x) => { + if (x.retryInfo.active) { + x.retryInfo = initRetryInfo(); + } + return x; + }); +} + +async function incrementDepositRetry( + ws: InternalWalletState, + depositGroupId: string, + err: TalerErrorDetails | undefined, +): Promise<void> { + await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { + const r = await tx.get(Stores.depositGroups, depositGroupId); + if (!r) { + return; + } + if (!r.retryInfo) { + return; + } + r.retryInfo.retryCounter++; + updateRetryInfoTimeout(r.retryInfo); + r.lastError = err; + await tx.put(Stores.depositGroups, r); + }); + if (err) { + ws.notify({ type: NotificationType.DepositOperationError, error: err }); + } +} + +export async function processDepositGroup( + ws: InternalWalletState, + depositGroupId: string, + forceNow = false, +): Promise<void> { + await ws.memoProcessDeposit.memo(depositGroupId, async () => { + const onOpErr = (e: TalerErrorDetails): Promise<void> => + incrementDepositRetry(ws, depositGroupId, e); + return await guardOperationException( + async () => await processDepositGroupImpl(ws, depositGroupId, forceNow), + onOpErr, + ); + }); +} + +async function processDepositGroupImpl( + ws: InternalWalletState, + depositGroupId: string, + forceNow: boolean = false, +): Promise<void> { + if (forceNow) { + await resetDepositGroupRetry(ws, depositGroupId); + } + const depositGroup = await ws.db.get(Stores.depositGroups, depositGroupId); + if (!depositGroup) { + logger.warn(`deposit group ${depositGroupId} not found`); + return; + } + if (depositGroup.timestampFinished) { + logger.trace(`deposit group ${depositGroupId} already finished`); + return; + } + + const contractData = extractContractData( + depositGroup.contractTermsRaw, + depositGroup.contractTermsHash, + "", + ); + + const depositPermissions = await generateDepositPermissions( + ws, + depositGroup.payCoinSelection, + contractData, + ); + + for (let i = 0; i < depositPermissions.length; i++) { + if (depositGroup.depositedPerCoin[i]) { + continue; + } + const perm = depositPermissions[i]; + const url = new URL(`/coins/${perm.coin_pub}/deposit`, perm.exchange_url); + const httpResp = await ws.http.postJson(url.href, { + contribution: Amounts.stringify(perm.contribution), + wire: depositGroup.wire, + h_wire: depositGroup.contractTermsRaw.h_wire, + h_contract_terms: depositGroup.contractTermsHash, + ub_sig: perm.ub_sig, + timestamp: depositGroup.contractTermsRaw.timestamp, + wire_transfer_deadline: + depositGroup.contractTermsRaw.wire_transfer_deadline, + refund_deadline: depositGroup.contractTermsRaw.refund_deadline, + coin_sig: perm.coin_sig, + denom_pub_hash: perm.h_denom, + merchant_pub: depositGroup.merchantPub, + }); + await readSuccessResponseJsonOrThrow(httpResp, codecForDepositSuccess()); + await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { + const dg = await tx.get(Stores.depositGroups, depositGroupId); + if (!dg) { + return; + } + dg.depositedPerCoin[i] = true; + await tx.put(Stores.depositGroups, dg); + }); + } + + await ws.db.runWithWriteTransaction([Stores.depositGroups], async (tx) => { + const dg = await tx.get(Stores.depositGroups, depositGroupId); + if (!dg) { + return; + } + let allDeposited = true; + for (const d of depositGroup.depositedPerCoin) { + if (!d) { + allDeposited = false; + } + } + if (allDeposited) { + dg.timestampFinished = getTimestampNow(); + await tx.put(Stores.depositGroups, dg); + } + }); +} + + +export async function trackDepositGroup( + ws: InternalWalletState, + req: TrackDepositGroupRequest, +): Promise<TrackDepositGroupResponse> { + const responses: { + status: number; + body: any; + }[] = []; + const depositGroup = await ws.db.get( + Stores.depositGroups, + req.depositGroupId, + ); + if (!depositGroup) { + throw Error("deposit group not found"); + } + const contractData = extractContractData( + depositGroup.contractTermsRaw, + depositGroup.contractTermsHash, + "", + ); + + const depositPermissions = await generateDepositPermissions( + ws, + depositGroup.payCoinSelection, + contractData, + ); + + const wireHash = depositGroup.contractTermsRaw.h_wire; + + for (const dp of depositPermissions) { + const url = new URL( + `/deposits/${wireHash}/${depositGroup.merchantPub}/${depositGroup.contractTermsHash}/${dp.coin_pub}`, + dp.exchange_url, + ); + const sig = await ws.cryptoApi.signTrackTransaction({ + coinPub: dp.coin_pub, + contractTermsHash: depositGroup.contractTermsHash, + merchantPriv: depositGroup.merchantPriv, + merchantPub: depositGroup.merchantPub, + wireHash, + }); + url.searchParams.set("merchant_sig", sig); + const httpResp = await ws.http.get(url.href); + const body = await httpResp.json(); + responses.push({ + body, + status: httpResp.status, + }); + } + return { + responses, + }; +} + +export async function createDepositGroup( + ws: InternalWalletState, + req: CreateDepositGroupRequest, +): Promise<CreateDepositGroupResponse> { + const p = parsePaytoUri(req.depositPaytoUri); + if (!p) { + throw Error("invalid payto URI"); + } + + const amount = Amounts.parseOrThrow(req.amount); + + const allExchanges = await ws.db.iter(Stores.exchanges).toArray(); + const exchangeInfos: { url: string; master_pub: string }[] = []; + for (const e of allExchanges) { + if (!e.details) { + continue; + } + if (e.details.currency != amount.currency) { + continue; + } + exchangeInfos.push({ + master_pub: e.details.masterPublicKey, + url: e.baseUrl, + }); + } + + const timestamp = getTimestampNow(); + const timestampRound = timestampTruncateToSecond(timestamp); + const noncePair = await ws.cryptoApi.createEddsaKeypair(); + const merchantPair = await ws.cryptoApi.createEddsaKeypair(); + const wireSalt = encodeCrock(getRandomBytes(64)); + const wireHash = hashWire(req.depositPaytoUri, wireSalt); + const contractTerms: ContractTerms = { + auditors: [], + exchanges: exchangeInfos, + amount: req.amount, + max_fee: Amounts.stringify(amount), + max_wire_fee: Amounts.stringify(amount), + wire_method: p.targetType, + timestamp: timestampRound, + merchant_base_url: "", + summary: "", + nonce: noncePair.pub, + wire_transfer_deadline: timestampRound, + order_id: "", + h_wire: wireHash, + pay_deadline: timestampAddDuration( + timestampRound, + durationFromSpec({ hours: 1 }), + ), + merchant: { + name: "", + }, + merchant_pub: merchantPair.pub, + refund_deadline: { t_ms: 0 }, + }; + + const contractTermsHash = await ws.cryptoApi.hashString( + canonicalJson(contractTerms), + ); + + const contractData = extractContractData( + contractTerms, + contractTermsHash, + "", + ); + + const payCoinSel = await getCoinsForPayment(ws, contractData); + + if (!payCoinSel) { + throw Error("insufficient funds"); + } + + const totalDepositCost = await getTotalPaymentCost(ws, payCoinSel); + + const depositGroupId = encodeCrock(getRandomBytes(32)); + + const effectiveDepositAmount = await getEffectiveDepositAmount( + ws, + p.targetType, + payCoinSel, + ); + + const depositGroup: DepositGroupRecord = { + contractTermsHash, + contractTermsRaw: contractTerms, + depositGroupId, + noncePriv: noncePair.priv, + noncePub: noncePair.pub, + timestampCreated: timestamp, + timestampFinished: undefined, + payCoinSelection: payCoinSel, + depositedPerCoin: payCoinSel.coinPubs.map((x) => false), + merchantPriv: merchantPair.priv, + merchantPub: merchantPair.pub, + totalPayCost: totalDepositCost, + effectiveDepositAmount, + wire: { + payto_uri: req.depositPaytoUri, + salt: wireSalt, + }, + retryInfo: initRetryInfo(true), + lastError: undefined, + }; + + await ws.db.runWithWriteTransaction( + [ + Stores.depositGroups, + Stores.coins, + Stores.refreshGroups, + Stores.denominations, + ], + async (tx) => { + await applyCoinSpend(ws, tx, payCoinSel); + await tx.put(Stores.depositGroups, depositGroup); + }, + ); + + await ws.db.put(Stores.depositGroups, depositGroup); + + return { depositGroupId }; +} diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index ee42d347e..d8168acdf 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -36,6 +36,8 @@ import { DenominationRecord, PayCoinSelection, AbortStatus, + AllowedExchangeInfo, + AllowedAuditorInfo, } from "../types/dbTypes"; import { NotificationType } from "../types/notifications"; import { @@ -43,6 +45,7 @@ import { codecForContractTerms, CoinDepositPermission, codecForMerchantPayResponse, + ContractTerms, } from "../types/talerTypes"; import { ConfirmPayResult, @@ -72,7 +75,8 @@ import { durationMin, isTimestampExpired, durationMul, - durationAdd, + Timestamp, + timestampIsBetween, } from "../util/time"; import { strcmp, canonicalJson } from "../util/helpers"; import { @@ -88,6 +92,7 @@ import { updateRetryInfoTimeout, getRetryDuration, } from "../util/retries"; +import { TransactionHandle } from "../util/query"; /** * Logger. @@ -163,6 +168,49 @@ export async function getTotalPaymentCost( } /** + * Get the amount that will be deposited on the merchant's bank + * account, not considering aggregation. + */ +export async function getEffectiveDepositAmount( + ws: InternalWalletState, + wireType: string, + pcs: PayCoinSelection, +): Promise<AmountJson> { + const amt: AmountJson[] = []; + const fees: AmountJson[] = []; + const exchangeSet: Set<string> = new Set(); + for (let i = 0; i < pcs.coinPubs.length; i++) { + const coin = await ws.db.get(Stores.coins, pcs.coinPubs[i]); + if (!coin) { + throw Error("can't calculate deposit amountt, coin not found"); + } + const denom = await ws.db.get(Stores.denominations, [ + coin.exchangeBaseUrl, + coin.denomPubHash, + ]); + if (!denom) { + throw Error("can't find denomination to calculate deposit amount"); + } + amt.push(pcs.coinContributions[i]); + fees.push(denom.feeDeposit); + exchangeSet.add(coin.exchangeBaseUrl); + } + for (const exchangeUrl of exchangeSet.values()) { + const exchange = await ws.db.get(Stores.exchanges, exchangeUrl); + if (!exchange?.wireInfo) { + continue; + } + const fee = exchange.wireInfo.feesForType[wireType].find((x) => { + return timestampIsBetween(getTimestampNow(), x.startStamp, x.endStamp); + })?.wireFee; + if (fee) { + fees.push(fee); + } + } + return Amounts.sub(Amounts.sum(amt).amount, Amounts.sum(fees).amount).amount; +} + +/** * Given a list of available coins, select coins to spend under the merchant's * constraints. * @@ -277,17 +325,36 @@ export function isSpendableCoin( return true; } +export interface CoinSelectionRequest { + amount: AmountJson; + allowedAuditors: AllowedAuditorInfo[]; + allowedExchanges: AllowedExchangeInfo[]; + + /** + * Timestamp of the contract. + */ + timestamp: Timestamp; + + wireMethod: string; + + wireFeeAmortization: number; + + maxWireFee: AmountJson; + + maxDepositFee: AmountJson; +} + /** * Select coins from the wallet's database that can be used * to pay for the given contract. * * If payment is impossible, undefined is returned. */ -async function getCoinsForPayment( +export async function getCoinsForPayment( ws: InternalWalletState, - contractData: WalletContractData, + req: CoinSelectionRequest, ): Promise<PayCoinSelection | undefined> { - const remainingAmount = contractData.amount; + const remainingAmount = req.amount; const exchanges = await ws.db.iter(Stores.exchanges).toArray(); @@ -303,7 +370,7 @@ async function getCoinsForPayment( } // is the exchange explicitly allowed? - for (const allowedExchange of contractData.allowedExchanges) { + for (const allowedExchange of req.allowedExchanges) { if (allowedExchange.exchangePub === exchangeDetails.masterPublicKey) { isOkay = true; break; @@ -312,7 +379,7 @@ async function getCoinsForPayment( // is the exchange allowed because of one of its auditors? if (!isOkay) { - for (const allowedAuditor of contractData.allowedAuditors) { + for (const allowedAuditor of req.allowedAuditors) { for (const auditor of exchangeDetails.auditors) { if (auditor.auditor_pub === allowedAuditor.auditorPub) { isOkay = true; @@ -374,11 +441,8 @@ async function getCoinsForPayment( } let wireFee: AmountJson | undefined; - for (const fee of exchangeFees.feesForType[contractData.wireMethod] || []) { - if ( - fee.startStamp <= contractData.timestamp && - fee.endStamp >= contractData.timestamp - ) { + for (const fee of exchangeFees.feesForType[req.wireMethod] || []) { + if (fee.startStamp <= req.timestamp && fee.endStamp >= req.timestamp) { wireFee = fee.wireFee; break; } @@ -386,12 +450,9 @@ async function getCoinsForPayment( let customerWireFee: AmountJson; - if (wireFee) { - const amortizedWireFee = Amounts.divide( - wireFee, - contractData.wireFeeAmortization, - ); - if (Amounts.cmp(contractData.maxWireFee, amortizedWireFee) < 0) { + if (wireFee && req.wireFeeAmortization) { + const amortizedWireFee = Amounts.divide(wireFee, req.wireFeeAmortization); + if (Amounts.cmp(req.maxWireFee, amortizedWireFee) < 0) { customerWireFee = amortizedWireFee; } else { customerWireFee = Amounts.getZero(currency); @@ -405,7 +466,7 @@ async function getCoinsForPayment( acis, remainingAmount, customerWireFee, - contractData.maxDepositFee, + req.maxDepositFee, ); if (res) { return res; @@ -414,6 +475,37 @@ async function getCoinsForPayment( return undefined; } +export async function applyCoinSpend( + ws: InternalWalletState, + tx: TransactionHandle< + | typeof Stores.coins + | typeof Stores.refreshGroups + | typeof Stores.denominations + >, + coinSelection: PayCoinSelection, +) { + for (let i = 0; i < coinSelection.coinPubs.length; i++) { + const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]); + if (!coin) { + throw Error("coin allocated for payment doesn't exist anymore"); + } + coin.status = CoinStatus.Dormant; + const remaining = Amounts.sub( + coin.currentAmount, + coinSelection.coinContributions[i], + ); + if (remaining.saturated) { + throw Error("not enough remaining balance on coin for payment"); + } + coin.currentAmount = remaining.amount; + await tx.put(Stores.coins, coin); + } + const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({ + coinPub: x, + })); + await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay); +} + /** * Record all information that is necessary to * pay for a proposal in the wallet's database. @@ -480,26 +572,7 @@ async function recordConfirmPay( await tx.put(Stores.proposals, p); } await tx.put(Stores.purchases, t); - for (let i = 0; i < coinSelection.coinPubs.length; i++) { - const coin = await tx.get(Stores.coins, coinSelection.coinPubs[i]); - if (!coin) { - throw Error("coin allocated for payment doesn't exist anymore"); - } - coin.status = CoinStatus.Dormant; - const remaining = Amounts.sub( - coin.currentAmount, - coinSelection.coinContributions[i], - ); - if (remaining.saturated) { - throw Error("not enough remaining balance on coin for payment"); - } - coin.currentAmount = remaining.amount; - await tx.put(Stores.coins, coin); - } - const refreshCoinPubs = coinSelection.coinPubs.map((x) => ({ - coinPub: x, - })); - await createRefreshGroup(ws, tx, refreshCoinPubs, RefreshReason.Pay); + await applyCoinSpend(ws, tx, coinSelection); }, ); @@ -609,6 +682,50 @@ function getPayRequestTimeout(purchase: PurchaseRecord): Duration { ); } +export function extractContractData( + parsedContractTerms: ContractTerms, + contractTermsHash: string, + merchantSig: string, +): WalletContractData { + const amount = Amounts.parseOrThrow(parsedContractTerms.amount); + let maxWireFee: AmountJson; + if (parsedContractTerms.max_wire_fee) { + maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); + } else { + maxWireFee = Amounts.getZero(amount.currency); + } + return { + amount, + contractTermsHash: contractTermsHash, + fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", + merchantBaseUrl: parsedContractTerms.merchant_base_url, + merchantPub: parsedContractTerms.merchant_pub, + merchantSig, + orderId: parsedContractTerms.order_id, + summary: parsedContractTerms.summary, + autoRefund: parsedContractTerms.auto_refund, + maxWireFee, + payDeadline: parsedContractTerms.pay_deadline, + refundDeadline: parsedContractTerms.refund_deadline, + wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, + allowedAuditors: parsedContractTerms.auditors.map((x) => ({ + auditorBaseUrl: x.url, + auditorPub: x.auditor_pub, + })), + allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ + exchangeBaseUrl: x.url, + exchangePub: x.master_pub, + })), + timestamp: parsedContractTerms.timestamp, + wireMethod: parsedContractTerms.wire_method, + wireInfoHash: parsedContractTerms.h_wire, + maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), + merchant: parsedContractTerms.merchant, + products: parsedContractTerms.products, + summaryI18n: parsedContractTerms.summary_i18n, + }; +} + async function processDownloadProposalImpl( ws: InternalWalletState, proposalId: string, @@ -714,6 +831,12 @@ async function processDownloadProposalImpl( throw new OperationFailedAndReportedError(err); } + const contractData = extractContractData( + parsedContractTerms, + contractTermsHash, + proposalResp.sig, + ); + await ws.db.runWithWriteTransaction( [Stores.proposals, Stores.purchases], async (tx) => { @@ -724,44 +847,8 @@ async function processDownloadProposalImpl( if (p.proposalStatus !== ProposalStatus.DOWNLOADING) { return; } - const amount = Amounts.parseOrThrow(parsedContractTerms.amount); - let maxWireFee: AmountJson; - if (parsedContractTerms.max_wire_fee) { - maxWireFee = Amounts.parseOrThrow(parsedContractTerms.max_wire_fee); - } else { - maxWireFee = Amounts.getZero(amount.currency); - } p.download = { - contractData: { - amount, - contractTermsHash: contractTermsHash, - fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "", - merchantBaseUrl: parsedContractTerms.merchant_base_url, - merchantPub: parsedContractTerms.merchant_pub, - merchantSig: proposalResp.sig, - orderId: parsedContractTerms.order_id, - summary: parsedContractTerms.summary, - autoRefund: parsedContractTerms.auto_refund, - maxWireFee, - payDeadline: parsedContractTerms.pay_deadline, - refundDeadline: parsedContractTerms.refund_deadline, - wireFeeAmortization: parsedContractTerms.wire_fee_amortization || 1, - allowedAuditors: parsedContractTerms.auditors.map((x) => ({ - auditorBaseUrl: x.url, - auditorPub: x.auditor_pub, - })), - allowedExchanges: parsedContractTerms.exchanges.map((x) => ({ - exchangeBaseUrl: x.url, - exchangePub: x.master_pub, - })), - timestamp: parsedContractTerms.timestamp, - wireMethod: parsedContractTerms.wire_method, - wireInfoHash: parsedContractTerms.h_wire, - maxDepositFee: Amounts.parseOrThrow(parsedContractTerms.max_fee), - merchant: parsedContractTerms.merchant, - products: parsedContractTerms.products, - summaryI18n: parsedContractTerms.summary_i18n, - }, + contractData, contractTermsRaw: proposalResp.contract_terms, }; if ( @@ -1210,7 +1297,7 @@ export async function preparePayForUri( * * Accesses the database and the crypto worker. */ -async function generateDepositPermissions( +export async function generateDepositPermissions( ws: InternalWalletState, payCoinSel: PayCoinSelection, contractData: WalletContractData, diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts index cc693a49d..bae281937 100644 --- a/packages/taler-wallet-core/src/operations/pending.ts +++ b/packages/taler-wallet-core/src/operations/pending.ts @@ -445,6 +445,34 @@ async function gatherRecoupPending( }); } +async function gatherDepositPending( + tx: TransactionHandle<typeof Stores.depositGroups>, + now: Timestamp, + resp: PendingOperationsResponse, + onlyDue = false, +): Promise<void> { + await tx.iter(Stores.depositGroups).forEach((dg) => { + if (dg.timestampFinished) { + return; + } + resp.nextRetryDelay = updateRetryDelay( + resp.nextRetryDelay, + now, + dg.retryInfo.nextRetry, + ); + if (onlyDue && dg.retryInfo.nextRetry.t_ms > now.t_ms) { + return; + } + resp.pendingOperations.push({ + type: PendingOperationType.Deposit, + givesLifeness: true, + depositGroupId: dg.depositGroupId, + retryInfo: dg.retryInfo, + lastError: dg.lastError, + }); + }); +} + export async function getPendingOperations( ws: InternalWalletState, { onlyDue = false } = {}, @@ -462,6 +490,7 @@ export async function getPendingOperations( Stores.purchases, Stores.recoupGroups, Stores.planchets, + Stores.depositGroups, ], async (tx) => { const walletBalance = await getBalancesInsideTransaction(ws, tx); @@ -479,6 +508,7 @@ export async function getPendingOperations( await gatherTipPending(tx, now, resp, onlyDue); await gatherPurchasePending(tx, now, resp, onlyDue); await gatherRecoupPending(tx, now, resp, onlyDue); + await gatherDepositPending(tx, now, resp, onlyDue); return resp; }, ); diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts index 13df438e4..28d48d5ba 100644 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ b/packages/taler-wallet-core/src/operations/refund.ts @@ -600,6 +600,7 @@ async function processPurchaseQueryRefundImpl( `orders/${purchase.download.contractData.orderId}/refund`, purchase.download.contractData.merchantBaseUrl, ); + logger.trace(`making refund request to ${requestUrl.href}`); diff --git a/packages/taler-wallet-core/src/operations/state.ts b/packages/taler-wallet-core/src/operations/state.ts index 645ad8ad3..ce52affe4 100644 --- a/packages/taler-wallet-core/src/operations/state.ts +++ b/packages/taler-wallet-core/src/operations/state.ts @@ -41,6 +41,7 @@ export class InternalWalletState { memoGetBalance: AsyncOpMemoSingle<BalancesResponse> = new AsyncOpMemoSingle(); memoProcessRefresh: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); memoProcessRecoup: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); + memoProcessDeposit: AsyncOpMemoMap<void> = new AsyncOpMemoMap(); cryptoApi: CryptoApi; listeners: NotificationListener[] = []; diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index c7e6a9c53..d49031551 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -96,6 +96,7 @@ export async function getTransactions( Stores.withdrawalGroups, Stores.planchets, Stores.recoupGroups, + Stores.depositGroups, ], // Report withdrawals that are currently in progress. async (tx) => { @@ -203,6 +204,28 @@ export async function getTransactions( }); }); + tx.iter(Stores.depositGroups).forEachAsync(async (dg) => { + const amount = Amounts.parseOrThrow(dg.contractTermsRaw.amount); + if (shouldSkipCurrency(transactionsRequest, amount.currency)) { + return; + } + + transactions.push({ + type: TransactionType.Deposit, + amountRaw: Amounts.stringify(dg.effectiveDepositAmount), + amountEffective: Amounts.stringify(dg.totalPayCost), + pending: !dg.timestampFinished, + timestamp: dg.timestampCreated, + targetPaytoUri: dg.wire.payto_uri, + transactionId: makeEventId( + TransactionType.Deposit, + dg.depositGroupId, + ), + depositGroupId: dg.depositGroupId, + ...(dg.lastError ? { error: dg.lastError } : {}), + }); + }); + tx.iter(Stores.purchases).forEachAsync(async (pr) => { if ( shouldSkipCurrency( diff --git a/packages/taler-wallet-core/src/types/cryptoTypes.ts b/packages/taler-wallet-core/src/types/cryptoTypes.ts index eb18d83fc..9b67b5963 100644 --- a/packages/taler-wallet-core/src/types/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/types/cryptoTypes.ts @@ -131,3 +131,11 @@ export interface DerivedTipPlanchet { coinPriv: string; coinPub: string; } + +export interface SignTrackTransactionRequest { + contractTermsHash: string; + wireHash: string; + coinPub: string; + merchantPriv: string; + merchantPub: string; +} diff --git a/packages/taler-wallet-core/src/types/dbTypes.ts b/packages/taler-wallet-core/src/types/dbTypes.ts index e0d137535..bc7d7728d 100644 --- a/packages/taler-wallet-core/src/types/dbTypes.ts +++ b/packages/taler-wallet-core/src/types/dbTypes.ts @@ -32,6 +32,7 @@ import { Product, InternationalizedString, AmountString, + ContractTerms, } from "./talerTypes"; import { Index, Store } from "../util/query"; @@ -1481,6 +1482,54 @@ export interface BackupProviderRecord { lastError: TalerErrorDetails | undefined; } +/** + * Group of deposits made by the wallet. + */ +export interface DepositGroupRecord { + depositGroupId: string; + + merchantPub: string; + merchantPriv: string; + + noncePriv: string; + noncePub: string; + + /** + * Wire information used by all deposits in this + * deposit group. + */ + wire: { + payto_uri: string; + salt: string; + }; + + /** + * Verbatim contract terms. + */ + contractTermsRaw: ContractTerms; + + contractTermsHash: string; + + payCoinSelection: PayCoinSelection; + + totalPayCost: AmountJson; + + effectiveDepositAmount: AmountJson; + + depositedPerCoin: boolean[]; + + timestampCreated: Timestamp; + + timestampFinished: Timestamp | undefined; + + lastError: TalerErrorDetails | undefined; + + /** + * Retry info. + */ + retryInfo: RetryInfo; +} + class ExchangesStore extends Store<"exchanges", ExchangeRecord> { constructor() { super("exchanges", { keyPath: "baseUrl" }); @@ -1657,6 +1706,12 @@ class BackupProvidersStore extends Store< } } +class DepositGroupsStore extends Store<"depositGroups", DepositGroupRecord> { + constructor() { + super("depositGroups", { keyPath: "depositGroupId" }); + } +} + /** * The stores and indices for the wallet database. */ @@ -1683,6 +1738,7 @@ export const Stores = { planchets: new PlanchetsStore(), bankWithdrawUris: new BankWithdrawUrisStore(), backupProviders: new BackupProvidersStore(), + depositGroups: new DepositGroupsStore(), }; export class MetaConfigStore extends Store<"metaConfig", ConfigRecord<any>> { diff --git a/packages/taler-wallet-core/src/types/notifications.ts b/packages/taler-wallet-core/src/types/notifications.ts index 8601c65b3..edfb377b9 100644 --- a/packages/taler-wallet-core/src/types/notifications.ts +++ b/packages/taler-wallet-core/src/types/notifications.ts @@ -60,6 +60,7 @@ export enum NotificationType { PendingOperationProcessed = "pending-operation-processed", ProposalRefused = "proposal-refused", ReserveRegisteredWithBank = "reserve-registered-with-bank", + DepositOperationError = "deposit-operation-error", } export interface ProposalAcceptedNotification { @@ -193,6 +194,11 @@ export interface RecoupOperationErrorNotification { error: TalerErrorDetails; } +export interface DepositOperationErrorNotification { + type: NotificationType.DepositOperationError; + error: TalerErrorDetails; +} + export interface ReserveOperationErrorNotification { type: NotificationType.ReserveOperationError; error: TalerErrorDetails; @@ -256,6 +262,7 @@ export type WalletNotification = | WithdrawalGroupCreatedNotification | CoinWithdrawnNotification | RecoupOperationErrorNotification + | DepositOperationErrorNotification | InternalErrorNotification | PendingOperationProcessedNotification | ProposalRefusedNotification diff --git a/packages/taler-wallet-core/src/types/pendingTypes.ts b/packages/taler-wallet-core/src/types/pendingTypes.ts index 18d9a2fa4..d41d2a977 100644 --- a/packages/taler-wallet-core/src/types/pendingTypes.ts +++ b/packages/taler-wallet-core/src/types/pendingTypes.ts @@ -40,6 +40,7 @@ export enum PendingOperationType { TipChoice = "tip-choice", TipPickup = "tip-pickup", Withdraw = "withdraw", + Deposit = "deposit", } /** @@ -60,6 +61,7 @@ export type PendingOperationInfo = PendingOperationInfoCommon & | PendingTipPickupOperation | PendingWithdrawOperation | PendingRecoupOperation + | PendingDepositOperation ); /** @@ -228,6 +230,16 @@ export interface PendingWithdrawOperation { } /** + * Status of an ongoing deposit operation. + */ +export interface PendingDepositOperation { + type: PendingOperationType.Deposit; + lastError: TalerErrorDetails | undefined; + retryInfo: RetryInfo; + depositGroupId: string; +} + +/** * Fields that are present in every pending operation. */ export interface PendingOperationInfoCommon { diff --git a/packages/taler-wallet-core/src/types/talerTypes.ts b/packages/taler-wallet-core/src/types/talerTypes.ts index 80aa1fe37..f3749afe7 100644 --- a/packages/taler-wallet-core/src/types/talerTypes.ts +++ b/packages/taler-wallet-core/src/types/talerTypes.ts @@ -484,7 +484,7 @@ export class ContractTerms { /** * Extra data, interpreted by the mechant only. */ - extra: any; + extra?: any; } /** diff --git a/packages/taler-wallet-core/src/types/transactionsTypes.ts b/packages/taler-wallet-core/src/types/transactionsTypes.ts index 0a683f298..81dc78039 100644 --- a/packages/taler-wallet-core/src/types/transactionsTypes.ts +++ b/packages/taler-wallet-core/src/types/transactionsTypes.ts @@ -94,7 +94,8 @@ export type Transaction = | TransactionPayment | TransactionRefund | TransactionTip - | TransactionRefresh; + | TransactionRefresh + | TransactionDeposit; export enum TransactionType { Withdrawal = "withdrawal", @@ -102,6 +103,7 @@ export enum TransactionType { Refund = "refund", Refresh = "refresh", Tip = "tip", + Deposit = "deposit", } export enum WithdrawalType { @@ -308,6 +310,31 @@ interface TransactionRefresh extends TransactionCommon { amountEffective: AmountString; } +/** + * Deposit transaction, which effectively sends + * money from this wallet somewhere else. + */ +interface TransactionDeposit extends TransactionCommon { + type: TransactionType.Deposit; + + depositGroupId: string; + + /** + * Target for the deposit. + */ + targetPaytoUri: string; + + /** + * Raw amount that is being deposited + */ + amountRaw: AmountString; + + /** + * Effective amount that is being deposited + */ + amountEffective: AmountString; +} + export const codecForTransactionsRequest = (): Codec<TransactionsRequest> => buildCodecForObject<TransactionsRequest>() .property("currency", codecOptional(codecForString())) diff --git a/packages/taler-wallet-core/src/types/walletTypes.ts b/packages/taler-wallet-core/src/types/walletTypes.ts index 235ea11f1..f195918ac 100644 --- a/packages/taler-wallet-core/src/types/walletTypes.ts +++ b/packages/taler-wallet-core/src/types/walletTypes.ts @@ -1006,3 +1006,38 @@ export const codecForAbortPayWithRefundRequest = (): Codec< buildCodecForObject<AbortPayWithRefundRequest>() .property("proposalId", codecForString()) .build("AbortPayWithRefundRequest"); + +export interface CreateDepositGroupRequest { + depositPaytoUri: string; + amount: string; +} + +export const codecForCreateDepositGroupRequest = (): Codec< + CreateDepositGroupRequest +> => + buildCodecForObject<CreateDepositGroupRequest>() + .property("amount", codecForAmountString()) + .property("depositPaytoUri", codecForString()) + .build("CreateDepositGroupRequest"); + +export interface CreateDepositGroupResponse { + depositGroupId: string; +} + +export interface TrackDepositGroupRequest { + depositGroupId: string; +} + +export interface TrackDepositGroupResponse { + responses: { + status: number; + body: any; + }[]; +} + +export const codecForTrackDepositGroupRequest = (): Codec< + TrackDepositGroupRequest +> => + buildCodecForObject<TrackDepositGroupRequest>() + .property("depositGroupId", codecForAmountString()) + .build("TrackDepositGroupRequest"); diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 65b816cc3..51987c349 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -53,6 +53,7 @@ import { CoinSourceType, RefundState, MetaStores, + DepositGroupRecord, } from "./types/dbTypes"; import { CoinDumpJson, WithdrawUriInfoResponse } from "./types/talerTypes"; import { @@ -96,6 +97,12 @@ import { codecForAbortPayWithRefundRequest, ApplyRefundResponse, RecoveryLoadRequest, + codecForCreateDepositGroupRequest, + CreateDepositGroupRequest, + CreateDepositGroupResponse, + codecForTrackDepositGroupRequest, + TrackDepositGroupRequest, + TrackDepositGroupResponse, } from "./types/walletTypes"; import { Logger } from "./util/logging"; @@ -173,6 +180,11 @@ import { BackupInfo, loadBackupRecovery, } from "./operations/backup"; +import { + createDepositGroup, + processDepositGroup, + trackDepositGroup, +} from "./operations/deposits"; const builtinCurrencies: CurrencyRecord[] = [ { @@ -299,6 +311,9 @@ export class Wallet { case PendingOperationType.ExchangeCheckRefresh: await autoRefresh(this.ws, pending.exchangeBaseUrl); break; + case PendingOperationType.Deposit: + await processDepositGroup(this.ws, pending.depositGroupId); + break; default: assertUnreachable(pending); } @@ -972,6 +987,12 @@ export class Wallet { return addBackupProvider(this.ws, req); } + async createDepositGroup( + req: CreateDepositGroupRequest, + ): Promise<CreateDepositGroupResponse> { + return createDepositGroup(this.ws, req); + } + async runBackupCycle(): Promise<void> { return runBackupCycle(this.ws); } @@ -980,6 +1001,12 @@ export class Wallet { return getBackupInfo(this.ws); } + async trackDepositGroup( + req: TrackDepositGroupRequest, + ): Promise<TrackDepositGroupResponse> { + return trackDepositGroup(this.ws, req); + } + /** * Implementation of the "wallet-core" API. */ @@ -1141,6 +1168,13 @@ export class Wallet { await runBackupCycle(this.ws); return {}; } + case "createDepositGroup": { + const req = codecForCreateDepositGroupRequest().decode(payload); + return await createDepositGroup(this.ws, req); + } + case "trackDepositGroup": + const req = codecForTrackDepositGroupRequest().decode(payload); + return trackDepositGroup(this.ws, req); } throw OperationFailedError.fromCode( TalerErrorCode.WALLET_CORE_API_OPERATION_UNKNOWN, |