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