commit d6d3104341b2c2f77c4f571726a55b1004c3acc1
parent 8472edd564a1c09ecf4516e8bd30c7a0a11ba88e
Author: Florian Dold <dold@taler.net>
Date: Fri, 4 Sep 2026 01:00:40 +0200
wallet-core: verify signed exchange recovery responses
Check purse status, purse deposits, refunds, melts, recoups, and coin
history before recovery. Retain recoup request inputs across restarts.
Diffstat:
4 files changed, 552 insertions(+), 30 deletions(-)
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts
@@ -22,6 +22,7 @@ import {
bufferFromAmount,
createEddsaKeyPair,
createHashContext,
+ CoinSpendHistoryItem,
decodeCrock,
durationRoundedToBuffer,
DenomKeyType,
@@ -349,6 +350,144 @@ test("purse merge signature binds the purse, reserve and timestamp", async () =>
);
});
+test("purse merge confirmation binds the complete exchange outcome", async () => {
+ const exchangeTimestamp = t(1_500);
+ const purseExpiration = t(2_500);
+ const mergeAmount = "TESTKUDOS:4.5" as AmountString;
+ const pursePub = encodeCrock(new Uint8Array(32).fill(31));
+ const reservePub = encodeCrock(new Uint8Array(32).fill(32));
+ const contractTermsHash = encodeCrock(new Uint8Array(64).fill(33));
+ const exchangeBaseUrl = "https://exchange.example/";
+ const sigBlob = buildSigPS(
+ TalerSignaturePurpose.EXCHANGE_CONFIRM_PURSE_MERGED,
+ )
+ .put(timestampRoundedToBuffer(exchangeTimestamp))
+ .put(timestampRoundedToBuffer(purseExpiration))
+ .put(bufferFromAmount(Amounts.parseOrThrow(mergeAmount)))
+ .put(decodeCrock(pursePub))
+ .put(decodeCrock(reservePub))
+ .put(decodeCrock(contractTermsHash))
+ .put(hash(stringToBytes(exchangeBaseUrl + "\0")))
+ .build();
+ const request = {
+ exchangeTimestamp,
+ purseExpiration,
+ mergeAmount,
+ pursePub,
+ reservePub,
+ contractTermsHash,
+ exchangeBaseUrl,
+ exchangePub: encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString,
+ exchangeSig: encodeCrock(
+ eddsaSign(sigBlob, signKey.eddsaPriv),
+ ) as EddsaSignatureString,
+ };
+
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidPurseMergeConfirmation(nativeCryptoR, request),
+ { valid: true },
+ );
+ for (const changed of [
+ { mergeAmount: "TESTKUDOS:4.6" as AmountString },
+ { exchangeBaseUrl: "https://other.example/" },
+ { reservePub: encodeCrock(new Uint8Array(32).fill(34)) },
+ ]) {
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidPurseMergeConfirmation(nativeCryptoR, {
+ ...request,
+ ...changed,
+ }),
+ { valid: false },
+ );
+ }
+});
+
+test("coin history verifies purse deposit and refund evidence", async () => {
+ const coin = createEddsaKeyPair();
+ const coinPub = encodeCrock(coin.eddsaPub) as EddsaPublicKeyString;
+ const pursePub = encodeCrock(new Uint8Array(32).fill(41));
+ const denomPubHash = encodeCrock(new Uint8Array(64).fill(42));
+ const exchangeBaseUrl = "https://exchange.example/";
+ const depositAmount = "TESTKUDOS:3" as AmountString;
+ const depositFee = "TESTKUDOS:0.01" as AmountString;
+ const refundAmount = "TESTKUDOS:2.98" as AmountString;
+ const refundFee = "TESTKUDOS:0.02" as AmountString;
+ const depositPreimage = buildSigPS(TalerSignaturePurpose.WALLET_PURSE_DEPOSIT)
+ .put(bufferFromAmount(Amounts.parseOrThrow(depositAmount)))
+ .put(decodeCrock(denomPubHash))
+ .put(new Uint8Array(32))
+ .put(decodeCrock(pursePub))
+ .put(hash(stringToBytes(exchangeBaseUrl + "\0")))
+ .build();
+ const refundPreimage = buildSigPS(
+ TalerSignaturePurpose.EXCHANGE_CONFIRM_PURSE_REFUND,
+ )
+ .put(decodeCrock(pursePub))
+ .put(decodeCrock(coinPub))
+ .put(bufferFromAmount(Amounts.parseOrThrow(refundAmount)))
+ .put(bufferFromAmount(Amounts.parseOrThrow(refundFee)))
+ .build();
+ const history: CoinSpendHistoryItem[] = [
+ {
+ type: "PURSE-DEPOSIT" as const,
+ history_offset: 1,
+ amount: depositAmount,
+ exchange_base_url: exchangeBaseUrl,
+ deposit_fee: depositFee,
+ purse_pub: pursePub,
+ refunded: true,
+ coin_sig: encodeCrock(
+ eddsaSign(depositPreimage, coin.eddsaPriv),
+ ) as EddsaSignatureString,
+ h_denom_pub: denomPubHash,
+ },
+ {
+ type: "PURSE-REFUND" as const,
+ history_offset: 2,
+ amount: refundAmount,
+ refund_fee: refundFee,
+ purse_pub: pursePub,
+ exchange_pub: encodeCrock(signKey.eddsaPub) as EddsaPublicKeyString,
+ exchange_sig: encodeCrock(
+ eddsaSign(refundPreimage, signKey.eddsaPriv),
+ ) as EddsaSignatureString,
+ },
+ ];
+ const request = {
+ coinPub,
+ denomPubHash,
+ feeDeposit: depositFee,
+ feeRefresh: "TESTKUDOS:0.03" as AmountString,
+ feeRefund: refundFee,
+ history,
+ };
+
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidCoinHistory(nativeCryptoR, request),
+ { valid: true },
+ );
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidCoinHistory(nativeCryptoR, {
+ ...request,
+ history: history.map((item) =>
+ item.type === "PURSE-DEPOSIT" ? { ...item, refunded: false } : item,
+ ),
+ }),
+ { valid: false },
+ );
+ assert.deepStrictEqual(
+ await nativeCryptoR.isValidCoinHistory(nativeCryptoR, {
+ ...request,
+ history: history.map((item) =>
+ item.type === "PURSE-REFUND"
+ ? { ...item, amount: "TESTKUDOS:2.97" as AmountString }
+ : item,
+ ),
+ }),
+ { valid: false },
+ );
+});
+
test("deposit confirmation binds the request and every coin signature", async () => {
const contractTermsHash = encodeCrock(new Uint8Array(64).fill(10));
const wireHash = encodeCrock(new Uint8Array(64).fill(11));
diff --git a/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/cryptoImplementation.ts
@@ -27,6 +27,7 @@
import {
AgeCommitmentProof,
AgeRestriction,
+ assertUnreachable,
AmountJson,
Amounts,
AmountString,
@@ -39,6 +40,7 @@ import {
canonicalJson,
CoinDepositPermission,
CoinEnvelope,
+ CoinSpendHistoryItem,
createHashContext,
csBlind,
csUnblind,
@@ -268,6 +270,14 @@ export interface TalerCryptoInterface {
req: PurseMergeSignatureValidationRequest,
): Promise<ValidationResult>;
+ isValidPurseMergeConfirmation(
+ req: PurseMergeConfirmationValidationRequest,
+ ): Promise<ValidationResult>;
+
+ isValidCoinHistory(
+ req: CoinHistoryValidationRequest,
+ ): Promise<ValidationResult>;
+
isValidDepositConfirmation(
req: DepositConfirmationValidationRequest,
): Promise<ValidationResult>;
@@ -506,6 +516,16 @@ export const nullCrypto: TalerCryptoInterface = {
): Promise<ValidationResult> {
throw new Error("Function not implemented.");
},
+ isValidPurseMergeConfirmation: function (
+ req: PurseMergeConfirmationValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
+ isValidCoinHistory: function (
+ req: CoinHistoryValidationRequest,
+ ): Promise<ValidationResult> {
+ throw new Error("Function not implemented.");
+ },
isValidDepositConfirmation: function (
req: DepositConfirmationValidationRequest,
): Promise<ValidationResult> {
@@ -1003,6 +1023,27 @@ export interface PurseMergeSignatureValidationRequest {
mergeSig: EddsaSignatureString;
}
+export interface PurseMergeConfirmationValidationRequest {
+ exchangeTimestamp: TalerProtocolTimestamp;
+ purseExpiration: TalerProtocolTimestamp;
+ mergeAmount: AmountString;
+ pursePub: EddsaPublicKeyString;
+ reservePub: EddsaPublicKeyString;
+ contractTermsHash: HashCodeString;
+ exchangeBaseUrl: string;
+ exchangePub: EddsaPublicKeyString;
+ exchangeSig: EddsaSignatureString;
+}
+
+export interface CoinHistoryValidationRequest {
+ coinPub: EddsaPublicKeyString;
+ denomPubHash: HashCodeString;
+ feeDeposit: AmountString;
+ feeRefresh: AmountString;
+ feeRefund: AmountString;
+ history: CoinSpendHistoryItem[];
+}
+
export interface DepositConfirmationValidationRequest {
contractTermsHash: HashCodeString;
wireHash: HashCodeString;
@@ -1491,21 +1532,18 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
const coinPriv = decodeCrock(req.coinPriv);
const coinSig = eddsaSign(p, coinPriv);
- if (req.denomPub.cipher === DenomKeyType.Rsa) {
- const paybackRequest: RecoupRequest = {
- coin_blind_key_secret: req.blindingKey,
- coin_sig: encodeCrock(coinSig),
- denom_pub_hash: req.denomPubHash,
- denom_sig: req.denomSig,
- // FIXME!
- ewv: {
- cipher: "RSA",
- },
- };
- return paybackRequest;
- } else {
- throw new Error();
- }
+ const paybackRequest: RecoupRequest = {
+ coin_blind_key_secret: req.blindingKey,
+ coin_sig: encodeCrock(coinSig),
+ denom_pub_hash: req.denomPubHash,
+ denom_sig: req.denomSig,
+ ewv: req.exchangeWithdrawValues,
+ h_planchets: req.hPlanchets,
+ withdraw_commitment_hash: req.withdrawCommitmentHash,
+ h_age_commitment: req.ageCommitmentHash,
+ nonce: req.nonce,
+ };
+ return paybackRequest;
},
/**
@@ -1522,21 +1560,16 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
const coinPriv = decodeCrock(req.coinPriv);
const coinSig = eddsaSign(p, coinPriv);
- if (req.denomPub.cipher === DenomKeyType.Rsa) {
- const recoupRequest: RecoupRefreshRequest = {
- coin_blind_key_secret: req.blindingKey,
- coin_sig: encodeCrock(coinSig),
- denom_pub_hash: req.denomPubHash,
- denom_sig: req.denomSig,
- // FIXME!
- ewv: {
- cipher: "RSA",
- },
- };
- return recoupRequest;
- } else {
- throw new Error();
- }
+ const recoupRequest: RecoupRefreshRequest = {
+ coin_blind_key_secret: req.blindingKey,
+ coin_sig: encodeCrock(coinSig),
+ denom_pub_hash: req.denomPubHash,
+ denom_sig: req.denomSig,
+ ewv: req.exchangeWithdrawValues,
+ h_age_commitment: req.ageCommitmentHash,
+ nonce: req.nonce,
+ };
+ return recoupRequest;
},
/**
@@ -1659,6 +1692,254 @@ export const nativeCryptoR: TalerCryptoInterfaceR = {
};
},
+ async isValidPurseMergeConfirmation(
+ tci: TalerCryptoInterfaceR,
+ req: PurseMergeConfirmationValidationRequest,
+ ): Promise<ValidationResult> {
+ const p = buildSigPS(TalerSignaturePurpose.EXCHANGE_CONFIRM_PURSE_MERGED)
+ .put(timestampRoundedToBuffer(req.exchangeTimestamp))
+ .put(timestampRoundedToBuffer(req.purseExpiration))
+ .put(bufferFromAmount(Amounts.parseOrThrow(req.mergeAmount)))
+ .put(decodeCrock(req.pursePub))
+ .put(decodeCrock(req.reservePub))
+ .put(decodeCrock(req.contractTermsHash))
+ .put(hash(stringToBytes(req.exchangeBaseUrl + "\0")))
+ .build();
+ return {
+ valid: eddsaVerify(
+ p,
+ decodeCrock(req.exchangeSig),
+ decodeCrock(req.exchangePub),
+ ),
+ };
+ },
+
+ async isValidCoinHistory(
+ tci: TalerCryptoInterfaceR,
+ req: CoinHistoryValidationRequest,
+ ): Promise<ValidationResult> {
+ try {
+ const coinPub = decodeCrock(req.coinPub);
+ const denomPubHash = decodeCrock(req.denomPubHash);
+ const zeroAge = new Uint8Array(32);
+ const zeroHash = new Uint8Array(64);
+ const refundedPurses = new Set(
+ req.history
+ .filter((x) => x.type === "PURSE-REFUND")
+ .map((x) => x.purse_pub),
+ );
+ const verify = (
+ preimage: Uint8Array,
+ signature: string,
+ publicKey: string,
+ ): boolean =>
+ eddsaVerify(preimage, decodeCrock(signature), decodeCrock(publicKey));
+ const feeMatches = (actual: AmountString, expected: AmountString) =>
+ Amounts.cmp(actual, expected) === 0;
+
+ for (const item of req.history) {
+ let valid: boolean;
+ switch (item.type) {
+ case "DEPOSIT": {
+ if (
+ item.h_denom_pub !== req.denomPubHash ||
+ !feeMatches(item.deposit_fee, req.feeDeposit)
+ ) {
+ return { valid: false };
+ }
+ const preimage = buildSigPS(
+ TalerSignaturePurpose.WALLET_COIN_DEPOSIT,
+ )
+ .put(decodeCrock(item.h_contract_terms))
+ .put(
+ item.h_age_commitment
+ ? decodeCrock(item.h_age_commitment)
+ : zeroAge,
+ )
+ .put(zeroHash)
+ .put(decodeCrock(item.h_wire))
+ .put(denomPubHash)
+ .put(timestampRoundedToBuffer(item.timestamp))
+ .put(
+ timestampRoundedToBuffer(
+ item.refund_deadline ?? TalerProtocolTimestamp.fromSeconds(0),
+ ),
+ )
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.deposit_fee)))
+ .put(decodeCrock(item.merchant_pub))
+ .put(
+ item.wallet_data_hash
+ ? decodeCrock(item.wallet_data_hash)
+ : zeroHash,
+ )
+ .build();
+ valid = verify(preimage, item.coin_sig, req.coinPub);
+ break;
+ }
+ case "MELT": {
+ if (
+ item.h_denom_pub !== req.denomPubHash ||
+ !feeMatches(item.melt_fee, req.feeRefresh)
+ ) {
+ return { valid: false };
+ }
+ const preimage = buildSigPS(TalerSignaturePurpose.WALLET_COIN_MELT)
+ .put(decodeCrock(item.rc))
+ .put(denomPubHash)
+ .put(
+ item.h_age_commitment
+ ? decodeCrock(item.h_age_commitment)
+ : zeroAge,
+ )
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.melt_fee)))
+ .build();
+ valid = verify(preimage, item.coin_sig, req.coinPub);
+ break;
+ }
+ case "REFUND": {
+ if (!feeMatches(item.refund_fee, req.feeRefund)) {
+ return { valid: false };
+ }
+ const signedAmount = Amounts.add(item.amount, item.refund_fee);
+ if (signedAmount.saturated) {
+ return { valid: false };
+ }
+ const preimage = buildSigPS(TalerSignaturePurpose.MERCHANT_REFUND)
+ .put(decodeCrock(item.h_contract_terms))
+ .put(coinPub)
+ .put(bufferForUint64(item.rtransaction_id))
+ .put(bufferFromAmount(signedAmount.amount))
+ .build();
+ valid = verify(preimage, item.merchant_sig, item.merchant_pub);
+ break;
+ }
+ case "RECOUP-WITHDRAW": {
+ if (item.h_denom_pub !== req.denomPubHash) {
+ return { valid: false };
+ }
+ const exchangePreimage = buildSigPS(
+ TalerSignaturePurpose.EXCHANGE_CONFIRM_RECOUP,
+ )
+ .put(timestampRoundedToBuffer(item.timestamp))
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(coinPub)
+ .put(decodeCrock(item.reserve_pub))
+ .build();
+ const coinPreimage = buildSigPS(
+ TalerSignaturePurpose.WALLET_COIN_RECOUP,
+ )
+ .put(denomPubHash)
+ .put(decodeCrock(item.coin_blind))
+ .build();
+ valid =
+ verify(exchangePreimage, item.exchange_sig, item.exchange_pub) &&
+ verify(coinPreimage, item.coin_sig, req.coinPub);
+ break;
+ }
+ case "RECOUP-REFRESH": {
+ const exchangePreimage = buildSigPS(
+ TalerSignaturePurpose.EXCHANGE_CONFIRM_RECOUP_REFRESH,
+ )
+ .put(timestampRoundedToBuffer(item.timestamp))
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(coinPub)
+ .put(decodeCrock(item.old_coin_pub))
+ .build();
+ const coinPreimage = buildSigPS(
+ TalerSignaturePurpose.WALLET_COIN_RECOUP_REFRESH,
+ )
+ .put(denomPubHash)
+ .put(decodeCrock(item.coin_blind))
+ .build();
+ valid =
+ verify(exchangePreimage, item.exchange_sig, item.exchange_pub) &&
+ verify(coinPreimage, item.coin_sig, req.coinPub);
+ break;
+ }
+ case "RECOUP-REFRESH-RECEIVER": {
+ const preimage = buildSigPS(
+ TalerSignaturePurpose.EXCHANGE_CONFIRM_RECOUP_REFRESH,
+ )
+ .put(timestampRoundedToBuffer(item.timestamp))
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(decodeCrock(item.coin_pub))
+ .put(coinPub)
+ .build();
+ valid = verify(preimage, item.exchange_sig, item.exchange_pub);
+ break;
+ }
+ case "PURSE-DEPOSIT": {
+ if (
+ item.h_denom_pub !== req.denomPubHash ||
+ !feeMatches(item.deposit_fee, req.feeDeposit) ||
+ item.refunded !== refundedPurses.has(item.purse_pub)
+ ) {
+ return { valid: false };
+ }
+ const preimage = buildSigPS(
+ TalerSignaturePurpose.WALLET_PURSE_DEPOSIT,
+ )
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(denomPubHash)
+ .put(
+ item.h_age_commitment
+ ? decodeCrock(item.h_age_commitment)
+ : zeroAge,
+ )
+ .put(decodeCrock(item.purse_pub))
+ .put(hash(stringToBytes(item.exchange_base_url + "\0")))
+ .build();
+ valid = verify(preimage, item.coin_sig, req.coinPub);
+ break;
+ }
+ case "PURSE-REFUND": {
+ if (!feeMatches(item.refund_fee, req.feeRefund)) {
+ return { valid: false };
+ }
+ const preimage = buildSigPS(
+ TalerSignaturePurpose.EXCHANGE_CONFIRM_PURSE_REFUND,
+ )
+ .put(decodeCrock(item.purse_pub))
+ .put(coinPub)
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.amount)))
+ .put(bufferFromAmount(Amounts.parseOrThrow(item.refund_fee)))
+ .build();
+ valid = verify(preimage, item.exchange_sig, item.exchange_pub);
+ break;
+ }
+ case "RESERVE-OPEN-DEPOSIT": {
+ const preimage = buildSigPS(
+ TalerSignaturePurpose.WALLET_RESERVE_OPEN_DEPOSIT,
+ )
+ .put(decodeCrock(item.reserve_sig))
+ .put(
+ bufferFromAmount(Amounts.parseOrThrow(item.coin_contribution)),
+ )
+ .put(
+ item.h_age_commitment
+ ? decodeCrock(item.h_age_commitment)
+ : zeroAge,
+ )
+ .put(denomPubHash)
+ .build();
+ valid = verify(preimage, item.coin_sig, req.coinPub);
+ break;
+ }
+ default:
+ assertUnreachable(item);
+ }
+ if (!valid) {
+ return { valid: false };
+ }
+ }
+ return { valid: true };
+ } catch {
+ return { valid: false };
+ }
+ },
+
async isValidDepositConfirmation(
tci: TalerCryptoInterfaceR,
req: DepositConfirmationValidationRequest,
diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts
@@ -238,6 +238,11 @@ export interface CreateRecoupReqRequest {
denomPub: DenominationPubKey;
denomPubHash: string;
denomSig: UnblindedDenominationSignature;
+ exchangeWithdrawValues: ExchangeWithdrawValue;
+ hPlanchets: HashCodeString;
+ withdrawCommitmentHash?: HashCodeString;
+ ageCommitmentHash?: HashCodeString;
+ nonce?: HashCodeString;
}
/**
@@ -250,6 +255,9 @@ export interface CreateRecoupRefreshReqRequest {
denomPub: DenominationPubKey;
denomPubHash: string;
denomSig: UnblindedDenominationSignature;
+ exchangeWithdrawValues: ExchangeWithdrawValue;
+ ageCommitmentHash?: HashCodeString;
+ nonce?: HashCodeString;
}
export interface EncryptedContract {
diff --git a/packages/taler-wallet-core/src/exchange-signatures.ts b/packages/taler-wallet-core/src/exchange-signatures.ts
@@ -19,9 +19,12 @@ import {
AmountString,
Amounts,
BatchDepositSuccess,
+ CoinHistoryResponse,
+ DenominationInfo,
Duration,
EddsaPublicKeyString,
ExchangePurseStatus,
+ ExchangeMergeSuccessResponse,
ExchangeRefundSuccessResponse,
HashCodeString,
PurseCreateSuccessResponse,
@@ -118,6 +121,97 @@ export async function requireValidExchangePurseStatus(
}
}
+/** Verify and bind the signed confirmation returned by purse merge. */
+export async function requireValidExchangePurseMergeConfirmation(
+ wex: WalletExecutionContext,
+ args: {
+ exchangeBaseUrl: string;
+ pursePub: string;
+ reservePub: string;
+ contractTermsHash: HashCodeString;
+ purseExpiration: TalerProtocolTimestamp;
+ response: ExchangeMergeSuccessResponse;
+ },
+): Promise<void> {
+ const [knownKey, signatureResult] = await Promise.all([
+ isKnownExchangeSigningKey(
+ wex,
+ args.exchangeBaseUrl,
+ args.response.exchange_pub,
+ AbsoluteTime.fromProtocolTimestamp(args.response.exchange_timestamp),
+ ),
+ wex.cryptoApi.isValidPurseMergeConfirmation({
+ exchangeTimestamp: args.response.exchange_timestamp,
+ purseExpiration: args.purseExpiration,
+ mergeAmount: args.response.merge_amount,
+ pursePub: args.pursePub,
+ reservePub: args.reservePub,
+ contractTermsHash: args.contractTermsHash,
+ exchangeBaseUrl: args.exchangeBaseUrl,
+ exchangePub: args.response.exchange_pub,
+ exchangeSig: args.response.exchange_sig,
+ }),
+ ]);
+ if (!knownKey || !signatureResult.valid) {
+ throw invalidExchangeSignature(
+ "exchange returned an invalid purse-merge confirmation signature",
+ );
+ }
+}
+
+/** Verify every authorization and exchange confirmation in a coin history. */
+export async function requireValidExchangeCoinHistory(
+ wex: WalletExecutionContext,
+ args: {
+ exchangeBaseUrl: string;
+ coinPub: EddsaPublicKeyString;
+ denomination: DenominationInfo;
+ response: CoinHistoryResponse;
+ },
+): Promise<void> {
+ const signatureResult = await wex.cryptoApi.isValidCoinHistory({
+ coinPub: args.coinPub,
+ denomPubHash: args.denomination.denomPubHash,
+ feeDeposit: args.denomination.feeDeposit,
+ feeRefresh: args.denomination.feeRefresh,
+ feeRefund: args.denomination.feeRefund,
+ history: args.response.history,
+ });
+ if (!signatureResult.valid) {
+ throw invalidExchangeSignature(
+ "exchange returned a coin history with invalid signatures or fees",
+ );
+ }
+
+ const exchangeSignatures = args.response.history.flatMap((item) => {
+ switch (item.type) {
+ case "RECOUP-WITHDRAW":
+ case "RECOUP-REFRESH":
+ case "RECOUP-REFRESH-RECEIVER":
+ return [
+ {
+ pub: item.exchange_pub,
+ at: AbsoluteTime.fromProtocolTimestamp(item.timestamp),
+ },
+ ];
+ case "PURSE-REFUND":
+ return [{ pub: item.exchange_pub, at: AbsoluteTime.now() }];
+ default:
+ return [];
+ }
+ });
+ const known = await Promise.all(
+ exchangeSignatures.map((sig) =>
+ isKnownExchangeSigningKey(wex, args.exchangeBaseUrl, sig.pub, sig.at),
+ ),
+ );
+ if (known.some((x) => !x)) {
+ throw invalidExchangeSignature(
+ "coin history was signed by an unknown exchange key",
+ );
+ }
+}
+
async function requireValidExchangePurseConfirmation(
wex: WalletExecutionContext,
args: {