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:
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,