taler-typescript-core

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

commit 286157f934293f2b4820efb5202f6600ae98d371
parent a75db4055341d077d00656bd7caff7109c8cc1dc
Author: Florian Dold <florian@dold.me>
Date:   Mon, 22 Jun 2026 16:42:30 +0200

wallet-core: introduce typed errors for API responses, use for getChoicesForPayment

Diffstat:
Mpackages/taler-harness/src/harness/harness.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 33+++++++++++++++++++++++++++++----
Mpackages/taler-wallet-core/src/remote.ts | 44++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 24++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/wallet.ts | 38++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-webextension/src/wxApi.ts | 59++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
6 files changed, 248 insertions(+), 41 deletions(-)

diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -43,9 +43,11 @@ import { MerchantAccountKycRedirectsResponse, MerchantAuthMethod, PaytoString, + Result, TalerCoreBankHttpClient, TalerCorebankApiClient, TalerError, + TalerErrorCode, TalerExchangeHttpClient, TalerMerchantApi, TalerMerchantInstanceHttpClient, @@ -79,9 +81,11 @@ import { import { WalletApiOperation, WalletCoreApiClient, + WalletCoreErrorType, WalletCoreRequestType, WalletCoreResponseType, WalletOperations, + walletApiExpectedErrors, } from "@gnu-taler/taler-wallet-core"; import { RemoteWallet, @@ -2979,43 +2983,70 @@ export class WalletCli { cliOpts: WalletCliOpts = {}, ) { const self = this; + const callInternal = async ( + op: any, + payload: any, + ): Promise<CoreApiResponse> => { + logger.info( + `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`, + ); + const cryptoWorkerArg = cliOpts.cryptoWorkerType + ? `--crypto-worker=${cliOpts.cryptoWorkerType}` + : ""; + const logName = `wallet-${self.name}`; + const command = `taler-wallet-cli ${ + self.timetravelArg ?? "" + } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${ + self.dbfile + }' api '${op}' ${shellWrap(JSON.stringify(payload))}`; + const resp = await sh(self.globalTestState, logName, command); + logger.trace(`command: ${j2s(command)}`); + logger.info("--- wallet core response ---"); + logger.info(resp); + logger.info("--- end of response ---"); + let ar: CoreApiResponse; + try { + ar = JSON.parse(resp); + } catch (e) { + throw new CommandError( + "wallet CLI did not return a proper JSON response", + logName, + command, + [], + {}, + null, + ); + } + return ar; + }; this._client = { async call(op: any, payload: any): Promise<any> { - logger.info( - `calling wallet with timetravel arg ${j2s(self.timetravelArg)}`, - ); - const cryptoWorkerArg = cliOpts.cryptoWorkerType - ? `--crypto-worker=${cliOpts.cryptoWorkerType}` - : ""; - const logName = `wallet-${self.name}`; - const command = `taler-wallet-cli ${ - self.timetravelArg ?? "" - } ${cryptoWorkerArg} --no-throttle -LTRACE --skip-defaults --wallet-db '${ - self.dbfile - }' api '${op}' ${shellWrap(JSON.stringify(payload))}`; - const resp = await sh(self.globalTestState, logName, command); - logger.trace(`command: ${j2s(command)}`); - logger.info("--- wallet core response ---"); - logger.info(resp); - logger.info("--- end of response ---"); - let ar: CoreApiResponse; - try { - ar = JSON.parse(resp); - } catch (e) { - throw new CommandError( - "wallet CLI did not return a proper JSON response", - logName, - command, - [], - {}, - null, - ); - } + const ar = await callInternal(op, payload); if (ar.type === "error") { throw TalerError.fromUncheckedDetail(ar.error); } return ar.result; }, + async callForResult(op: any, payload: any): Promise<any> { + const ar = await callInternal(op, payload); + if (ar.type === "error") { + if (op in walletApiExpectedErrors) { + const errs = ( + walletApiExpectedErrors as { + [x: string]: readonly TalerErrorCode[]; + } + )[op]; + if (errs.includes(ar.error.code)) { + return Result.errorWithDetail( + ar.error.code as WalletCoreErrorType<any>, + ar.error, + ); + } + } + throw TalerError.fromUncheckedDetail(ar.error); + } + return Result.of(ar.result); + }, }; } diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -2454,17 +2454,42 @@ export async function getChoicesForPayment( throw Error("expected payment transaction ID"); } const proposalId = parsedTx.proposalId; - const proposal = await wex.runLegacyWalletDbTx(async (tx) => { - return tx.purchases.get(proposalId); + const { proposal, d } = await wex.runLegacyWalletDbTx(async (tx) => { + const proposal = await tx.purchases.get(proposalId); + let d: DownloadedContractData | undefined = undefined; + if (proposal) { + const download = proposal.download; + if (!download) { + return { proposal }; + } + const contractTermsRec = await tx.contractTerms.get( + download.contractTermsHash, + ); + if (contractTermsRec) { + d = { + contractTerms: codecForMerchantContractTerms().decode( + contractTermsRec.contractTermsRaw, + ), + contractTermsRaw: contractTermsRec.contractTermsRaw, + contractTermsHash: contractTermsRec.h, + }; + } + } + return { proposal, d }; }); if (!proposal) { throw Error(`proposal with id ${proposalId} not found`); } - const d = await expectProposalDownload(wex, proposal); if (!d) { - throw Error("proposal is in invalid state"); + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(proposal), + debugStateNum: proposal.purchaseStatus, + }, + ); } const choices: ChoiceSelectionDetail[] = []; diff --git a/packages/taler-wallet-core/src/remote.ts b/packages/taler-wallet-core/src/remote.ts @@ -15,6 +15,7 @@ */ import { + assertUnreachable, CoreApiRequestEnvelope, CoreApiResponse, encodeCrock, @@ -22,11 +23,20 @@ import { Logger, OpenedPromise, openPromise, + Result, TalerError, + TalerErrorCode, WalletNotification, } from "@gnu-taler/taler-util"; import { connectRpc, JsonMessage } from "@gnu-taler/taler-util/twrpc"; -import { WalletCoreApiClient } from "./wallet-api-types.js"; +import { + walletApiExpectedErrors, + WalletCoreApiClient, + WalletCoreErrorType, + WalletCoreRequestType, + WalletCoreResponseType, + WalletOperations, +} from "./wallet-api-types.js"; const logger = new Logger("remote.ts"); @@ -127,7 +137,7 @@ export async function createRemoteWallet( } /** - * Get a high-level API client from a remove wallet. + * Get a high-level API client from a remote wallet. */ export function getClientFromRemoteWallet( w: RemoteWallet, @@ -140,6 +150,36 @@ export function getClientFromRemoteWallet( throw TalerError.fromUncheckedDetail(res.error); case "response": return res.result; + default: + assertUnreachable(res); + } + }, + callForResult: async function <Op extends keyof WalletOperations>( + op: Op, + payload: WalletCoreRequestType<Op>, + ): Promise<Result<WalletCoreResponseType<Op>, WalletCoreErrorType<Op>>> { + const res = await w.makeCoreApiRequest(op, payload); + switch (res.type) { + case "error": { + if (op in walletApiExpectedErrors) { + const errs = ( + walletApiExpectedErrors as { + [x: string]: readonly TalerErrorCode[]; + } + )[op]; + if (errs.includes(res.error.code)) { + return Result.errorWithDetail( + res.error.code as WalletCoreErrorType<Op>, + res.error, + ); + } + } + throw TalerError.fromUncheckedDetail(res.error); + } + case "response": + return Result.of(res.result as WalletCoreResponseType<Op>); + default: + assertUnreachable(res); } }, }; diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -163,6 +163,7 @@ import { RecoverStoredBackupRequest, RemoveGlobalCurrencyAuditorRequest, RemoveGlobalCurrencyExchangeRequest, + Result, RetryTransactionRequest, RunFixupRequest, SendTalerUriMailboxMessageRequest, @@ -174,6 +175,7 @@ import { StartRefundQueryForUriResponse, StartRefundQueryRequest, StoredBackupList, + TalerErrorCode, TestPayArgs, TestPayResult, TestingCorruptWithdrawalCoinSelRequest, @@ -1626,6 +1628,15 @@ export type ForceRefreshOp = { response: EmptyObject; }; +/** + * Mapping from the wallet-core request type to a list of expected errors. + */ +export const walletApiExpectedErrors = { + [WalletApiOperation.GetChoicesForPayment]: [ + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + ], +} as const; + export type WalletOperations = { [WalletApiOperation.InitWallet]: InitWalletOp; [WalletApiOperation.SetWalletRunConfig]: SetWalletRunConfigOp; @@ -1777,6 +1788,14 @@ export type WalletCoreResponseType< Op extends WalletApiOperation & keyof WalletOperations, > = WalletOperations[Op] extends { response: infer T } ? T : never; +type ValOf<T extends readonly unknown[]> = T[number]; + +export type WalletCoreErrorType< + Op extends WalletApiOperation & keyof WalletOperations, +> = Op extends keyof typeof walletApiExpectedErrors + ? ValOf<(typeof walletApiExpectedErrors)[Op]> + : never; + export type WalletCoreOpKeys = WalletApiOperation & keyof WalletOperations; export interface WalletCoreApiClient { @@ -1784,4 +1803,9 @@ export interface WalletCoreApiClient { operation: Op, payload: WalletCoreRequestType<Op>, ): Promise<WalletCoreResponseType<Op>>; + + callForResult<Op extends keyof WalletOperations>( + operation: Op, + payload: WalletCoreRequestType<Op>, + ): Promise<Result<WalletCoreResponseType<Op>, WalletCoreErrorType<Op>>>; } diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -164,6 +164,7 @@ import { WalletRunConfig, WireTypeDetails, WithdrawTestBalanceRequest, + assertUnreachable, canonicalizeBaseUrl, checkDbInvariant, codecForAbortTransaction, @@ -454,8 +455,11 @@ import { import { WalletApiOperation, WalletCoreApiClient, + WalletCoreErrorType, WalletCoreRequestType, WalletCoreResponseType, + WalletOperations, + walletApiExpectedErrors, } from "./wallet-api-types.js"; import { acceptBankIntegratedWithdrawal, @@ -770,6 +774,40 @@ async function getClientFromWalletState( return res.result; } }, + callForResult: async function <Op extends keyof WalletOperations>( + op: Op, + payload: WalletCoreRequestType<Op>, + ): Promise<Result<WalletCoreResponseType<Op>, WalletCoreErrorType<Op>>> { + id = (id + 1) % (Number.MAX_SAFE_INTEGER - 100); + const res = await dispatchWalletCoreApiRequest( + ws, + op, + String(id), + payload, + ); + switch (res.type) { + case "error": { + if (op in walletApiExpectedErrors) { + const errs = ( + walletApiExpectedErrors as { + [x: string]: readonly TalerErrorCode[]; + } + )[op]; + if (errs.includes(res.error.code)) { + return Result.errorWithDetail( + res.error.code as WalletCoreErrorType<Op>, + res.error, + ); + } + } + throw TalerError.fromUncheckedDetail(res.error); + } + case "response": + return Result.of(res.result as WalletCoreResponseType<Op>); + default: + assertUnreachable(res); + } + }, }; return client; } diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts @@ -28,16 +28,20 @@ import { Logger, LogLevel, NotificationType, + Result, TalerError, TalerErrorCode, TalerErrorDetail, WalletNotification, } from "@gnu-taler/taler-util"; import { + walletApiExpectedErrors, WalletCoreApiClient, + WalletCoreErrorType, WalletCoreOpKeys, WalletCoreRequestType, WalletCoreResponseType, + WalletOperations, } from "@gnu-taler/taler-wallet-core"; import { ExtensionNotification, @@ -170,11 +174,10 @@ class BackgroundApiClientImpl implements BackgroundApiClient { * WalletCoreApiClient integration with browser platform */ class WalletApiClientImpl implements WalletCoreApiClient { - async call<Op extends WalletCoreOpKeys>( + async #callInternal<Op extends keyof WalletOperations>( operation: Op, payload: WalletCoreRequestType<Op>, - ): Promise<WalletCoreResponseType<Op>> { - let response: CoreApiResponse; + ): Promise<CoreApiResponse> { try { // FIXME: This type must be fixed and needs documentation! // @ts-ignore @@ -183,7 +186,7 @@ class WalletApiClientImpl implements WalletCoreApiClient { operation, payload, }; - response = await platform.sendMessageToBackground(message); + return await platform.sendMessageToBackground(message); } catch (error) { if (error instanceof Error) { throw new BackgroundError( @@ -197,6 +200,40 @@ class WalletApiClientImpl implements WalletCoreApiClient { } throw error; } + } + + async callForResult<Op extends keyof WalletOperations>( + operation: Op, + payload: WalletCoreRequestType<Op>, + ): Promise<Result<WalletCoreResponseType<Op>, WalletCoreErrorType<Op>>> { + const response = await this.#callInternal(operation, payload); + if (response.type === "error") { + if (operation in walletApiExpectedErrors) { + const errs = ( + walletApiExpectedErrors as { [x: string]: readonly TalerErrorCode[] } + )[operation]; + if (errs.includes(response.error.code)) { + return Result.errorWithDetail( + response.error.code as WalletCoreErrorType<Op>, + response.error, + ); + } + } + throw new BackgroundError( + `Wallet operation "${operation}" failed`, + response.error, + TalerError.fromUncheckedDetail(response.error), + ); + } + logger.trace("got response", response); + return Result.of(response.result as any); + } + + async call<Op extends WalletCoreOpKeys>( + operation: Op, + payload: WalletCoreRequestType<Op>, + ): Promise<WalletCoreResponseType<Op>> { + const response = await this.#callInternal(operation, payload); if (response.type === "error") { throw new BackgroundError( `Wallet operation "${operation}" failed`, @@ -245,7 +282,19 @@ function trigger(w: ExtensionNotification) { }); } -export const wxApi = { +type WxApi = { + wallet: WalletCoreApiClient; + background: BackgroundApiClient; + listener: { + trigger: (w: ExtensionNotification) => void; + onUpdateNotification: ( + messageTypes: Array<NotificationType>, + doCallback: undefined | ((n: WalletNotification) => void), + ) => () => void; + }; +}; + +export const wxApi: WxApi = { wallet: new WalletApiClientImpl(), background: new BackgroundApiClientImpl(), listener: {