taler-typescript-core

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

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:
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.test.ts | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/crypto/cryptoImplementation.ts | 341++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/crypto/cryptoTypes.ts | 8++++++++
Mpackages/taler-wallet-core/src/exchange-signatures.ts | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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: {