taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 509412474d1a296afa83e54fd10d6af7c2630ebc
parent ca1cc63dff297bbe3a8c07b65e1bf4933a7f9cf8
Author: Florian Dold <florian@dold.me>
Date:   Wed,  2 Apr 2025 01:33:42 +0200

wallet-core: use new withdrawal API

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-withdrawal-idempotent.ts | 10+++++-----
Mpackages/taler-util/src/http-client/exchange.ts | 19++++++++++---------
Mpackages/taler-util/src/taler-crypto.ts | 7+++++++
Mpackages/taler-util/src/types-taler-exchange.ts | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mpackages/taler-wallet-core/src/dbless.ts | 8++++----
Mpackages/taler-wallet-core/src/withdraw.ts | 268++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
7 files changed, 411 insertions(+), 104 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-idempotent.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-idempotent.ts @@ -21,9 +21,9 @@ import { AgeRestriction, Amounts, AmountString, - codecForExchangeWithdrawBatchResponse, + codecForExchangeLegacyWithdrawBatchResponse, encodeCrock, - ExchangeBatchWithdrawRequest, + ExchangeLegacyBatchWithdrawRequest, getRandomBytes, } from "@gnu-taler/taler-util"; import { @@ -119,7 +119,7 @@ async function myWithdrawCoin(args: { value: Amounts.parseOrThrow(denom.value), }); - const reqBody: ExchangeBatchWithdrawRequest = { + const reqBody: ExchangeLegacyBatchWithdrawRequest = { planchets: [ { denom_pub_hash: planchet.denomPubHash, @@ -136,7 +136,7 @@ async function myWithdrawCoin(args: { const resp = await http.fetch(reqUrl, { method: "POST", body: reqBody }); const rBatch = await readSuccessResponseJsonOrThrow( resp, - codecForExchangeWithdrawBatchResponse(), + codecForExchangeLegacyWithdrawBatchResponse(), ); { @@ -144,7 +144,7 @@ async function myWithdrawCoin(args: { const resp2 = await http.fetch(reqUrl, { method: "POST", body: reqBody }); await readSuccessResponseJsonOrThrow( resp2, - codecForExchangeWithdrawBatchResponse(), + codecForExchangeLegacyWithdrawBatchResponse(), ); } diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts @@ -47,8 +47,8 @@ import { import { AmlDecisionRequest, BatchWithdrawResponse, - ExchangeBatchWithdrawRequest, ExchangeKycUploadFormRequest, + ExchangeLegacyBatchWithdrawRequest, ExchangeVersionResponse, KycRequirementInformationId, WalletKycRequest, @@ -71,10 +71,10 @@ import { TalerError } from "../errors.js"; import { AmountJson, Amounts, - signKycAuth, - signWalletAccountSetup, signAmlDecision, signAmlQuery, + signKycAuth, + signWalletAccountSetup, } from "../index.js"; import { TalerErrorCode } from "../taler-error-codes.js"; import { AbsoluteTime } from "../time.js"; @@ -362,7 +362,7 @@ export class TalerExchangeHttpClient { * https://docs.taler.net/core/api-exchange.html#post--reserves-$RESERVE_PUB-batch-withdraw * */ - async withdraw(rid: ReservePub, body: ExchangeBatchWithdrawRequest) { + async withdraw(rid: ReservePub, body: ExchangeLegacyBatchWithdrawRequest) { const url = new URL(`reserves/${rid}/batch-withdraw`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { @@ -667,9 +667,7 @@ export class TalerExchangeHttpClient { const resp = await this.httpLib.fetch(url.href, { method: "GET", headers: { - "Account-Owner-Signature": encodeCrock( - signKycAuth(signingKey), - ), + "Account-Owner-Signature": encodeCrock(signKycAuth(signingKey)), }, }); @@ -741,7 +739,10 @@ export class TalerExchangeHttpClient { * https://docs.taler.net/core/api-exchange.html#post--kyc-upload-$ID * */ - async uploadKycForm<T extends ExchangeKycUploadFormRequest>(requirement: KycRequirementInformationId, body: T) { + async uploadKycForm<T extends ExchangeKycUploadFormRequest>( + requirement: KycRequirementInformationId, + body: T, + ) { const url = new URL(`kyc-upload/${requirement}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { @@ -1015,7 +1016,7 @@ export class TalerExchangeHttpClient { ), }, body, - compress: "deflate" + compress: "deflate", }); switch (resp.status) { diff --git a/packages/taler-util/src/taler-crypto.ts b/packages/taler-util/src/taler-crypto.ts @@ -1754,3 +1754,10 @@ export function durationRoundedToBuffer(ts: TalerProtocolDuration): Uint8Array { } return new Uint8Array(b); } + +export function toHexString(byteArray: Uint8Array) { + return byteArray.reduce( + (output, elem) => output + ("0" + elem.toString(16)).slice(-2), + "", + ); +} diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -889,14 +889,14 @@ export interface CoinEnvelopeCs { // FIXME: add remaining fields } -export interface ExchangeWithdrawRequest { +export interface ExchangeLegacyWithdrawRequest { denom_pub_hash: HashCodeString; reserve_sig: EddsaSignatureString; coin_ev: CoinEnvelope; } -export interface ExchangeBatchWithdrawRequest { - planchets: ExchangeWithdrawRequest[]; +export interface ExchangeLegacyBatchWithdrawRequest { + planchets: ExchangeLegacyWithdrawRequest[]; } export interface ExchangeRefreshRevealRequest { @@ -964,17 +964,31 @@ export const codecForRecoupConfirmation = (): Codec<RecoupConfirmation> => .property("old_coin_pub", codecOptional(codecForString())) .build("RecoupConfirmation"); -export const codecForWithdrawResponse = (): Codec<ExchangeWithdrawResponse> => - buildCodecForObject<ExchangeWithdrawResponse>() - .property("ev_sig", codecForBlindedDenominationSignature()) - .build("WithdrawResponse"); +export const codecForLegacyWithdrawResponse = + (): Codec<ExchangeLegacyWithdrawResponse> => + buildCodecForObject<ExchangeLegacyWithdrawResponse>() + .property("ev_sig", codecForBlindedDenominationSignature()) + .build("WithdrawResponse"); -export class ExchangeWithdrawResponse { +export class ExchangeLegacyWithdrawResponse { ev_sig: BlindedDenominationSignature; } -export class ExchangeWithdrawBatchResponse { - ev_sigs: ExchangeWithdrawResponse[]; +export class ExchangeLegacyWithdrawBatchResponse { + ev_sigs: ExchangeLegacyWithdrawResponse[]; +} + +/** + * Docs name: WithdrawResponse + */ +export interface ExchangeWithdrawResponse { + /** + * Array of blinded signatures over each coin_evs, + * in the same order as was given in the request. + * The blinded signatures affirm the coin's validity + * after unblinding. + */ + ev_sigs: BlindedDenominationSignature[]; } export enum DenomKeyType { @@ -1018,12 +1032,18 @@ export const codecForBlindedDenominationSignature = () => .alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature()) .build("BlindedDenominationSignature"); -export const codecForExchangeWithdrawBatchResponse = - (): Codec<ExchangeWithdrawBatchResponse> => - buildCodecForObject<ExchangeWithdrawBatchResponse>() - .property("ev_sigs", codecForList(codecForWithdrawResponse())) +export const codecForExchangeLegacyWithdrawBatchResponse = + (): Codec<ExchangeLegacyWithdrawBatchResponse> => + buildCodecForObject<ExchangeLegacyWithdrawBatchResponse>() + .property("ev_sigs", codecForList(codecForLegacyWithdrawResponse())) .build("WithdrawBatchResponse"); +export const codecForExchangeWithdrawResponse = + (): Codec<ExchangeWithdrawResponse> => + buildCodecForObject<ExchangeWithdrawResponse>() + .property("ev_sigs", codecForList(codecForBlindedDenominationSignature())) + .build("WithdrawResponse"); + export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> => buildCodecForObject<ExchangeMeltResponse>() .property("exchange_pub", codecForString()) @@ -2952,3 +2972,50 @@ interface DenominationExpiredMessage { // failed? oper: string; } + +/** + * Docs name: WithdrawRequest + */ +export interface ExchangeWithdrawRequest { + // Cipher that is used for the rerserve's signatures. + // For now, only ed25519 signatures are applicable, + // but this might change in future versions. + cipher: "ED25519"; + + // The reserve's public key, for the the cipher ED25519, + // to verify the signature reserve_sig. + reserve_pub: EddsaPublicKey; + + // Array of n hash codes of denomination public keys to order. + // The sum of all denomination's values and fees MUST be + // at most the balance of the reserve. The balance of + // the reserve will be immediatley reduced by that amount. + // If max_age is set, these denominations MUST support + // age restriction as defined in the output to /keys. + denoms_h: HashCode[]; + + // If set, the maximum age to commit to. This implies: + // 1.) it MUST be the same value as the maximum age + // of the reserve. + // 2.) coin_evs MUST be an array of n*kappa + // 3.) the denominations in denoms_h MUST support + // age restriction. + max_age?: number; + + // Array of blinded coin envelopes of type CoinEnvelope. + // If max_age is not set, MUST be n entries. + // If max_age is set, MUST be n*kappa entries, + // arranged in [0..n)..[0..n), with the first n entries + // belonging to kappa=0 etc. + // In case of age restriction, the exchange will + // respond with an index gamma, which is the index + // that shall remain undisclosed during the subsequent + // reveal phase. + // This hash value along with the reserve's public key + // will also be used for recoup operations, if needed. + coin_evs: CoinEnvelope[]; + + // Signature of TALER_WithdrawRequestPS created with + // the reserves's private key. + reserve_sig: EddsaSignature; +} diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts @@ -47,8 +47,10 @@ import { durationRoundedToBuffer, ecdhGetPublic, eddsaGetPublic, + EddsaPrivateKeyString, EddsaPublicKeyString, eddsaSign, + EddsaSignatureString, eddsaVerify, encodeCrock, encryptContractForDeposit, @@ -62,7 +64,6 @@ import { hashCoinEvInner, hashCoinPub, hashDenomPub, - hashPayWalletData, hashTokenEv, hashTokenIssuePub, hashTruncate32, @@ -94,7 +95,6 @@ import { TokenEnvelope, TokenIssueBlindSig, TokenIssuePublicKey, - TokenUseSig, UnblindedSignature, WireFee, WithdrawalPlanchet, @@ -146,6 +146,8 @@ export interface TalerCryptoInterface { */ createPlanchet(req: PlanchetCreationRequest): Promise<WithdrawalPlanchet>; + signWithdrawal(req: SignWithdrawalRequest): Promise<SignWithdrawalResponse>; + eddsaSign(req: EddsaSignRequest): Promise<EddsaSignResponse>; /** @@ -320,9 +322,7 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<DerivedTipPlanchet> { throw new Error("Function not implemented."); }, - createSlate: function ( - req: SlateCreationRequest, - ): Promise<Slate> { + createSlate: function (req: SlateCreationRequest): Promise<Slate> { throw new Error("Function not implemented."); }, signTokenUse: function ( @@ -528,6 +528,11 @@ export const nullCrypto: TalerCryptoInterface = { ): Promise<SignContractTermsHashResponse> { throw new Error("Function not implemented."); }, + signWithdrawal: function ( + req: SignWithdrawalRequest, + ): Promise<SignWithdrawalResponse> { + throw new Error("Function not implemented."); + }, }; export type WithArg<X> = X extends (req: infer T) => infer R @@ -556,6 +561,29 @@ export interface SetupWithdrawalPlanchetRequest { coinNumber: number; } +export interface SignWithdrawalRequest { + reservePriv: EddsaPrivateKeyString; + /** + * Total amount without fees. + */ + amount: AmountString; + + /** + * Total withdrawal fee. + */ + fee: AmountString; + + coinEvs: CoinEnvelope[]; + + denomsPubHashes: HashCodeString[]; + + // FIXME: Age restriction stuff +} + +export interface SignWithdrawalResponse { + sig: EddsaSignatureString; +} + export interface SignPurseCreationRequest { pursePriv: string; purseExpiration: TalerProtocolTimestamp; @@ -991,10 +1019,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { // token issue public key hash const tokenIssuePubHash = hashTokenIssuePub(req.tokenIssuePub); - const evHash = hashTokenEv( - tokenEv, - encodeCrock(tokenIssuePubHash), - ); + const evHash = hashTokenEv(tokenEv, encodeCrock(tokenIssuePubHash)); const choice = req.contractTerms.choices[req.outputIndex]; const tokenEvs: TokenEnvelope[] = []; @@ -1005,18 +1030,20 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { const slug = output.token_family_slug; const family = req.contractTerms.token_families[slug]; - tokenEvs.push(...family.keys.map(key => { - let tokenEv: TokenEnvelope; - if (key.cipher === DenomKeyType.Rsa) { - tokenEv = { - cipher: DenomKeyType.Rsa, - rsa_blinded_planchet: key.rsa_pub, - }; - } else { - throw Error(`unsupported cipher (${req.tokenIssuePub.cipher})`); - } - return tokenEv; - })); + tokenEvs.push( + ...family.keys.map((key) => { + let tokenEv: TokenEnvelope; + if (key.cipher === DenomKeyType.Rsa) { + tokenEv = { + cipher: DenomKeyType.Rsa, + rsa_blinded_planchet: key.rsa_pub, + }; + } else { + throw Error(`unsupported cipher (${req.tokenIssuePub.cipher})`); + } + return tokenEv; + }), + ); } // wallet data object with envelopes @@ -1041,9 +1068,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { tci: TalerCryptoInterfaceR, req: SignTokenUseRequest, ): Promise<EddsaSigningResult> { - const tokenUseRequest = buildSigPS( - TalerSignaturePurpose.WALLET_TOKEN_USE, - ) + const tokenUseRequest = buildSigPS(TalerSignaturePurpose.WALLET_TOKEN_USE) .put(decodeCrock(req.contractTermsHash)) .put(decodeCrock(req.walletDataHash)) .build(); @@ -1156,13 +1181,13 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { } if (req.sig.cipher === DenomKeyType.Rsa) { - const {valid} = await tci.rsaVerify(tci, { + const { valid } = await tci.rsaVerify(tci, { hm: req.tokenUsePub, pk: req.tokenIssuePub.rsa_pub, sig: req.sig.rsa_signature, }); - return {valid}; + return { valid }; } throw Error(`verification for ${req.sig.cipher} signature not implemented`); @@ -1330,9 +1355,7 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { ): Promise<UnblindedSignature> { if (req.evSig.cipher === DenomKeyType.Rsa) { if (req.slate.tokenIssuePub.cipher !== DenomKeyType.Rsa) { - throw new Error( - "slate cipher does not match blind signature cipher", - ); + throw new Error("slate cipher does not match blind signature cipher"); } const { sig } = await tci.rsaUnblind(tci, { bk: req.slate.blindingKey, @@ -2054,6 +2077,33 @@ export const nativeCryptoR: TalerCryptoInterfaceR = { sig: sigRes.sig, }; }, + + async signWithdrawal( + tci: TalerCryptoInterfaceR, + req: SignWithdrawalRequest, + ): Promise<SignWithdrawalResponse> { + const hc = createHashContext(); + + for (let i = 0; i < req.coinEvs.length; i++) { + hc.update(hashCoinEv(req.coinEvs[i], req.denomsPubHashes[i])); + } + + const hPlanchets = hc.finish(); + + const withdrawRequest = buildSigPS( + TalerSignaturePurpose.WALLET_RESERVE_WITHDRAW, + ) + .put(amountToBuffer(req.amount)) + .put(amountToBuffer(req.fee)) + .put(hPlanchets) + .put(bufferForUint32(0)) // max_age_group + .put(bufferForUint32(0)) // age mask + .build(); + const sig = eddsaSign(withdrawRequest, decodeCrock(req.reservePriv)); + return { + sig: encodeCrock(sig), + }; + }, }; export interface EddsaSignRequest { diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts @@ -35,7 +35,7 @@ import { EddsaPrivateKeyString, EddsaPublicKeyString, ExchangeBatchDepositRequest, - ExchangeBatchWithdrawRequest, + ExchangeLegacyBatchWithdrawRequest, ExchangeMeltRequest, ExchangeProtocolVersion, Logger, @@ -46,7 +46,7 @@ import { codecForBatchDepositSuccess, codecForExchangeMeltResponse, codecForExchangeRevealResponse, - codecForExchangeWithdrawBatchResponse, + codecForExchangeLegacyWithdrawBatchResponse, encodeCrock, getRandomBytes, hashWire, @@ -150,7 +150,7 @@ export async function withdrawCoin(args: { value: Amounts.parseOrThrow(denom.value), }); - const reqBody: ExchangeBatchWithdrawRequest = { + const reqBody: ExchangeLegacyBatchWithdrawRequest = { planchets: [ { denom_pub_hash: planchet.denomPubHash, @@ -167,7 +167,7 @@ export async function withdrawCoin(args: { const resp = await http.fetch(reqUrl, { method: "POST", body: reqBody }); const rBatch = await readSuccessResponseJsonOrThrow( resp, - codecForExchangeWithdrawBatchResponse(), + codecForExchangeLegacyWithdrawBatchResponse(), ); const ubSig = await cryptoApi.unblindDenominationSignature({ diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -34,7 +34,8 @@ import { AmountString, Amounts, BankWithdrawDetails, - CancellationToken, + BlindedDenominationSignature, + CoinEnvelope, CoinStatus, ConfirmWithdrawalRequest, CurrencySpecification, @@ -43,17 +44,18 @@ import { DenomSelectionState, Duration, EddsaPrivateKeyString, - ExchangeBatchWithdrawRequest, + ExchangeLegacyBatchWithdrawRequest, + ExchangeLegacyWithdrawRequest, ExchangeListItem, ExchangeUpdateStatus, ExchangeWalletKycStatus, ExchangeWireAccount, - ExchangeWithdrawBatchResponse, ExchangeWithdrawRequest, ExchangeWithdrawResponse, ExchangeWithdrawalDetails, ForcedDenomSel, GetWithdrawalDetailsForAmountRequest, + HashCode, HttpStatusCode, LibtoolVersion, Logger, @@ -92,7 +94,8 @@ import { codecForBankWithdrawalOperationStatus, codecForCashinConversionResponse, codecForConversionBankConfig, - codecForExchangeWithdrawBatchResponse, + codecForExchangeLegacyWithdrawBatchResponse, + codecForExchangeWithdrawResponse, codecForLegitimizationNeededResponse, codecForReserveStatus, encodeCrock, @@ -1416,7 +1419,7 @@ interface WithdrawalRequestBatchArgs { interface WithdrawalBatchResult { coinIdxs: number[]; - batchResp: ExchangeWithdrawBatchResponse; + batchResp: ExchangeWithdrawResponse; } /** @@ -1507,7 +1510,7 @@ async function handleKycRequired( * * The verification of the response is done asynchronously to enable parallelism. */ -async function processPlanchetExchangeBatchRequest( +async function processPlanchetExchangeLegacyBatchRequest( wex: WalletExecutionContext, wgContext: WithdrawalGroupStatusInfo, args: WithdrawalRequestBatchArgs, @@ -1522,7 +1525,7 @@ async function processPlanchetExchangeBatchRequest( ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; - const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] }; + const batchReq: ExchangeLegacyBatchWithdrawRequest = { planchets: [] }; // Indices of coins that are included in the batch request const requestCoinIdxs: number[] = []; @@ -1561,7 +1564,7 @@ async function processPlanchetExchangeBatchRequest( continue; } - const planchetReq: ExchangeWithdrawRequest = { + const planchetReq: ExchangeLegacyWithdrawRequest = { denom_pub_hash: planchet.denomPubHash, reserve_sig: planchet.withdrawSig, coin_ev: planchet.coinEv, @@ -1633,7 +1636,172 @@ async function processPlanchetExchangeBatchRequest( } const r = await readSuccessResponseJsonOrThrow( resp, - codecForExchangeWithdrawBatchResponse(), + codecForExchangeLegacyWithdrawBatchResponse(), + ); + return { + coinIdxs: requestCoinIdxs, + batchResp: { ev_sigs: r.ev_sigs.map((x) => x.ev_sig) }, + }; + } catch (e) { + const errDetail = getErrorDetailFromException(e); + // We don't know which coin is affected, so we store the error + // with the first coin of the batch. + await storeCoinError(errDetail, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } +} + +/** + * Send the withdrawal request for a generated planchet to the exchange. + * + * The verification of the response is done asynchronously to enable parallelism. + */ +async function processPlanchetExchangeBatchRequest( + wex: WalletExecutionContext, + wgContext: WithdrawalGroupStatusInfo, + args: WithdrawalRequestBatchArgs, +): Promise<WithdrawalBatchResult> { + const withdrawalGroup: WithdrawalGroupRecord = wgContext.wgRecord; + logger.info( + `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`, + ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + // Indices of coins that are included in the batch request + const requestCoinIdxs: number[] = []; + const coinEvs: CoinEnvelope[] = []; + const denomHashes: HashCode[] = []; + checkDbInvariant( + !!withdrawalGroup.instructedAmount, + "missing instructed amount in withdrawal group", + ); + let accAmount = Amounts.zeroOfAmount(withdrawalGroup.instructedAmount); + let accFee = Amounts.zeroOfAmount(withdrawalGroup.instructedAmount); + await wex.db.runReadOnlyTx( + { storeNames: ["planchets", "denominations"] }, + async (tx) => { + for ( + let coinIdx = args.coinStartIndex; + coinIdx < args.coinStartIndex + args.batchSize && + coinIdx < wgContext.numPlanchets; + coinIdx++ + ) { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + continue; + } + if (planchet.planchetStatus === PlanchetStatus.WithdrawalDone) { + logger.warn("processPlanchet: planchet already withdrawn"); + continue; + } + if (planchet.planchetStatus === PlanchetStatus.AbortedReplaced) { + continue; + } + const denom = await getDenomInfo( + wex, + tx, + exchangeBaseUrl, + planchet.denomPubHash, + ); + + if (!denom) { + logger.error("db inconsistent: denom for planchet not found"); + continue; + } + accAmount = Amounts.add(accAmount, denom.value).amount; + accFee = Amounts.add(accFee, denom.feeWithdraw).amount; + requestCoinIdxs.push(coinIdx); + coinEvs.push(planchet.coinEv); + denomHashes.push(planchet.denomPubHash); + } + }, + ); + + if (coinEvs.length == 0) { + logger.warn("empty withdrawal batch"); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + + async function storeCoinError( + errDetail: TalerErrorDetail, + coinIdx: number, + ): Promise<void> { + logger.trace(`withdrawal request failed: ${j2s(errDetail)}`); + await wex.db.runReadWriteTx({ storeNames: ["planchets"] }, async (tx) => { + const planchet = await tx.planchets.indexes.byGroupAndIndex.get([ + withdrawalGroup.withdrawalGroupId, + coinIdx, + ]); + if (!planchet) { + return; + } + planchet.lastError = errDetail; + await tx.planchets.put(planchet); + }); + } + + // FIXME: handle individual error codes better! + + const reqUrl = new URL(`withdraw`, withdrawalGroup.exchangeBaseUrl).href; + + // if (logger.shouldLogTrace()) { + // logger.trace(`batch-withdraw request: ${j2s(batchReq)}`); + // } + + const sigResp = await wex.cryptoApi.signWithdrawal({ + amount: Amounts.stringify(accAmount), + fee: Amounts.stringify(accFee), + coinEvs: coinEvs, + denomsPubHashes: denomHashes, + reservePriv: withdrawalGroup.reservePriv, + }); + + const batchReq: ExchangeWithdrawRequest = { + cipher: "ED25519", + reserve_pub: withdrawalGroup.reservePub, + coin_evs: coinEvs, + denoms_h: denomHashes, + reserve_sig: sigResp.sig, + }; + + try { + const resp = await wex.http.fetch(reqUrl, { + method: "POST", + body: batchReq, + cancellationToken: wex.cancellationToken, + timeout: Duration.fromSpec({ seconds: 40 }), + }); + if (resp.status === HttpStatusCode.UnavailableForLegalReasons) { + await handleKycRequired(wex, withdrawalGroup, resp, 0, requestCoinIdxs); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + if (resp.status === HttpStatusCode.Gone) { + const e = await readTalerErrorResponse(resp); + // FIXME: Store in place of the planchet that is actually affected! + await storeCoinError(e, requestCoinIdxs[0]); + return { + batchResp: { ev_sigs: [] }, + coinIdxs: [], + }; + } + const r = await readSuccessResponseJsonOrThrow( + resp, + codecForExchangeWithdrawResponse(), ); return { coinIdxs: requestCoinIdxs, @@ -1655,7 +1823,7 @@ async function processPlanchetVerifyAndStoreCoin( wex: WalletExecutionContext, wgContext: WithdrawalGroupStatusInfo, coinIdx: number, - resp: ExchangeWithdrawResponse, + resp: BlindedDenominationSignature, ): Promise<void> { const withdrawalGroup = wgContext.wgRecord; checkDbInvariant( @@ -1712,7 +1880,7 @@ async function processPlanchetVerifyAndStoreCoin( throw Error(`cipher (${planchetDenomPub.cipher}) not supported`); } - const evSig = resp.ev_sig; + const evSig = resp; if (!(evSig.cipher === DenomKeyType.Rsa)) { throw Error("unsupported cipher"); } @@ -2141,7 +2309,10 @@ async function processWithdrawalGroupPendingKyc( accountPriv: withdrawalGroup.reservePriv, accountPub: withdrawalGroup.reservePub, }); - const url = new URL(`kyc-check/${kycPaytoHash}`, withdrawalGroup.exchangeBaseUrl); + const url = new URL( + `kyc-check/${kycPaytoHash}`, + withdrawalGroup.exchangeBaseUrl, + ); url.searchParams.set("lpt", "3"); // wait for the KYC status to be OK logger.info(`long-polling for withdrawal KYC status via ${url.href}`); const kycStatusRes = await cancelableLongPool(wex, url, { @@ -2370,7 +2541,7 @@ async function processWithdrawalGroupPendingReady( ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; logger.trace(`updating exchange beofre processing wg`); - await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); + const exch = await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { logger.warn("Finishing empty withdrawal group (no denoms)"); @@ -2444,11 +2615,31 @@ async function processWithdrawalGroupPendingReady( const maxBatchSize = 100; + const exchangeVer = LibtoolVersion.parseVersion(exch.protocolVersionRange); + if (!exchangeVer) { + // Should never happen, as version range syntax is checked + // before info is added to DB. + throw TalerError.fromDetail( + TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE, + {}, + "exchange has invalid protocol version", + ); + } + for (let i = 0; i < numTotalCoins; i += maxBatchSize) { - const resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { - batchSize: maxBatchSize, - coinStartIndex: i, - }); + let resp: WithdrawalBatchResult; + if (exchangeVer.current >= 26) { + resp = await processPlanchetExchangeBatchRequest(wex, wgContext, { + batchSize: maxBatchSize, + coinStartIndex: i, + }); + } else { + resp = await processPlanchetExchangeLegacyBatchRequest(wex, wgContext, { + batchSize: maxBatchSize, + coinStartIndex: i, + }); + } + let work: Promise<void>[] = []; work = []; for (let j = 0; j < resp.coinIdxs.length; j++) { @@ -2643,13 +2834,10 @@ export async function getExchangeWithdrawalInfo( ); } - const withdrawalAccountsList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount, - } - ); + const withdrawalAccountsList = await fetchWithdrawalAccountInfo(wex, { + exchange, + instructedAmount, + }); logger.trace("updating withdrawal denoms"); await updateWithdrawalDenomsForExchange(wex, exchangeBaseUrl); @@ -2959,7 +3147,7 @@ async function registerReserveWithBank( const httpResp = await cancelableFetch(wex, bankStatusUrl, { method: "POST", body: reqBody, - timeout: getReserveRequestTimeout(withdrawalGroup) + timeout: getReserveRequestTimeout(withdrawalGroup), }); switch (httpResp.status) { @@ -3708,13 +3896,10 @@ export async function confirmWithdrawal( let withdrawalAccountList: WithdrawalExchangeAccountDetails[] = []; if (instructedAmount) { - withdrawalAccountList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount, - } - ); + withdrawalAccountList = await fetchWithdrawalAccountInfo(wex, { + exchange, + instructedAmount, + }); } const senderWire = withdrawalGroup.wgInfo.bankInfo.senderWire; @@ -3976,7 +4161,7 @@ async function fetchAccount( instructedAmount: AmountJson, scopeInfo: ScopeInfo, acct: ExchangeWireAccount, - reservePub: string | undefined + reservePub: string | undefined, ): Promise<WithdrawalExchangeAccountDetails> { let paytoUri: string; let transferAmount: AmountString | undefined; @@ -4067,7 +4252,7 @@ async function fetchWithdrawalAccountInfo( exchange: ReadyExchangeSummary; instructedAmount: AmountJson; reservePub?: string; - } + }, ): Promise<WithdrawalExchangeAccountDetails[]> { const { exchange } = req; const withdrawalAccounts: WithdrawalExchangeAccountDetails[] = []; @@ -4077,7 +4262,7 @@ async function fetchWithdrawalAccountInfo( req.instructedAmount, req.exchange.scopeInfo, acct, - req.reservePub + req.reservePub, ); withdrawalAccounts.push(acctInfo); } @@ -4136,14 +4321,11 @@ export async function createManualWithdrawal( reserveKeyPair = await wex.cryptoApi.createEddsaKeypair({}); } - const withdrawalAccountsList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount: amount, - reservePub: reserveKeyPair.pub, - } - ); + const withdrawalAccountsList = await fetchWithdrawalAccountInfo(wex, { + exchange, + instructedAmount: amount, + reservePub: reserveKeyPair.pub, + }); const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { amount: amount,