taler-typescript-core

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

commit 6083a83431ab755999a8c5e9e7d25c8fe3405160
parent bb9da11eb1070ea248b21e1d9026e00794797b57
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Tue, 14 Jul 2026 09:18:35 -0300

fix #11623 long polling issues

Diffstat:
Mpackages/libeufin-bank-webui/src/hooks/account.ts | 49+++++++++++++++++++++----------------------------
Mpackages/taler-exchange-kyc-webui/src/hooks/kyc.ts | 53++++++++++++++++++++++++++++-------------------------
Mpackages/taler-merchant-webui/src/Application.tsx | 3++-
Mpackages/taler-merchant-webui/src/context/currency.ts | 48++----------------------------------------------
Mpackages/taler-merchant-webui/src/hooks/instance.ts | 70+++++++++++++++++++++-------------------------------------------------
Mpackages/taler-merchant-webui/src/hooks/order.ts | 78+++++++++++++++++++++++++-----------------------------------------------------
Mpackages/taler-util/src/types-taler-merchant.ts | 22++++++++++++++++++++++
Mpackages/web-util/src/hooks/useAsync.ts | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
8 files changed, 189 insertions(+), 236 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/hooks/account.ts b/packages/libeufin-bank-webui/src/hooks/account.ts @@ -29,6 +29,7 @@ import { useSessionState } from "./session.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { + LONG_POLL_DELAY, PaginatedResult, useAsync, useBankCoreApiContext, @@ -85,41 +86,33 @@ export function useWithdrawalDetails(wid: string | undefined) { lib: { bank: api }, } = useBankCoreApiContext(); - //FIXME: use swr - const prev = useAsync( - wid === undefined - ? undefined - : () => { - return api.getWithdrawalById(wid, undefined); - }, - ); + type Res = undefined | Awaited<ReturnType<typeof api.getWithdrawalById>>; const result = useLongPolling( - prev, - (result) => { - if (!result || result instanceof TalerError || result.type === "fail") - return false; - const { status } = result.body; - return status === "pending" || status === "selected"; - }, - async (ct, r) => { - if ( - !r || - r instanceof TalerError || - r.type === "fail" || - r.body.status === "confirmed" || - r.body.status === "aborted" - ) { - return undefined; - } + async (ct, res): Promise<Res> => { + const old_state = + !res || res instanceof TalerError || res.type === "fail" + ? undefined + : res.body.status; const result = await api.getWithdrawalById(wid!, { - old_state: r.body.status, - timeoutMs: 5000, + old_state, + timeoutMs: LONG_POLL_DELAY, ct, }); return result; }, - [wid], + { + minTime: LONG_POLL_DELAY, + deps: [wid], + shouldStop(res) { + return ( + res !== undefined && + !(res instanceof TalerError) && + res.type !== "fail" && + (res.body.status === "confirmed" || res.body.status === "aborted") + ); + }, + }, ); return result; diff --git a/packages/taler-exchange-kyc-webui/src/hooks/kyc.ts b/packages/taler-exchange-kyc-webui/src/hooks/kyc.ts @@ -21,9 +21,9 @@ import { } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { - useAsync, + LONG_POLL_DELAY, useExchangeApiContext, - useLongPolling, + useLongPolling } from "@gnu-taler/web-util/browser"; import _useSWR, { mutate, SWRHook } from "swr"; const useSWR = _useSWR as unknown as SWRHook; @@ -41,36 +41,39 @@ export function useKycInfo(token?: AccessToken) { lib: { exchange: api }, } = useExchangeApiContext(); - const prev = useAsync( - token === undefined - ? undefined - : () => { - return api.checkKycInfoSpa(token, undefined); - }, - [token], - ); + type Res = undefined | Awaited<ReturnType<typeof api.checkKycInfoSpa>>; const result = useLongPolling( - prev, - (result) => { - if (!result || result instanceof TalerError || result.type === "fail") - return false; - if (!result.body.requirements.length) return false; - return true; - }, - async (ct, result) => { - if (!result || result instanceof TalerError || result.type === "fail") - return undefined; - if (!result.body.requirements.length) return undefined; - - const res = await api.checkKycInfoSpa(token!, result.body.etag, { - timeoutMs: 5000, + async (ct, result): Promise<Res> => { + const etag = + !result || result instanceof TalerError || result.type === "fail" + ? undefined + : result.body.etag; + const res = await api.checkKycInfoSpa(token!, etag, { + timeoutMs: LONG_POLL_DELAY, }); if (res.type === "fail" && res.case === HttpStatusCode.NotModified) { + if (!result || result instanceof TalerError || result.type === "fail") { + // this should not happen, if we get at 304 is because + // we used a etag + throw Error("server replied 304 not modified but we dont have previous response.") + } return opFixedSuccess(dummyHttpResponse, result.body); } return res; }, - [token], + { + deps: [token], + shouldStop(result) { + // if error keep trying + // stop when no requirements left + return ( + result !== undefined && + !(result instanceof TalerError) && + result.type !== "fail" && + result.body.requirements.length === 0 + ); + }, + }, ); return result; diff --git a/packages/taler-merchant-webui/src/Application.tsx b/packages/taler-merchant-webui/src/Application.tsx @@ -103,6 +103,7 @@ import { fetchSettings, } from "./settings.js"; import { SolveChallengeDialog } from "./components/SolveMFA.js"; +import { FullConfiguration } from "swr/_internal"; const TALER_SCREEN_ID = 2; const WITH_LOCAL_STORAGE_CACHE = false; @@ -158,7 +159,7 @@ function SubApp({ baseUrl }: { baseUrl: string }) { // do not go to loading again if already has data keepPreviousData: true, - }} + } as Partial<FullConfiguration>} > <MerchantChallengeHandlerProvider> <SessionContextProvider> diff --git a/packages/taler-merchant-webui/src/context/currency.ts b/packages/taler-merchant-webui/src/context/currency.ts @@ -23,10 +23,7 @@ import { } from "@gnu-taler/taler-util"; import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext, useState } from "preact/hooks"; -import { - useInstanceKYCDetails, - useInstanceKYCDetailsLongPolling, -} from "../hooks/instance.js"; +import { useInstanceKYCDetailsLongPolling } from "../hooks/instance.js"; import { useSessionContext } from "./session.js"; /** @@ -38,7 +35,7 @@ export type Type = { list: string[]; kycStatus: KycStatusMap; currency: CurrencyWithId; - kycResult: ReturnType<typeof useInstanceKYCDetails>; + kycResult: ReturnType<typeof useInstanceKYCDetailsLongPolling>; switchCurrency: (id: string) => void; }; @@ -144,44 +141,3 @@ export const CurrenciesProvider = ({ children, }); }; - -// export function useInstanceKYCSimplifiedWorstStatusLongPolling( -// account?: Paytos.URI, -// ) { -// const kycStatus = useInstanceKYCDetailsLongPolling(); - -// const allKycData = -// kycStatus !== undefined && -// !(kycStatus instanceof TalerError) && -// kycStatus.type === "ok" -// ? kycStatus.body.kyc_data -// : []; - -// const uri_str = -// account === undefined ? undefined : Paytos.toFullString(account); - -// const filtered = -// uri_str === undefined -// ? allKycData -// : allKycData.filter((data) => data.payto_uri === uri_str); - -// const r = filtered.reduce((prev, cur) => { -// const st = getMerchantAccountKycStatusSimplified(cur.status); -// if (st > prev) return st; -// return prev; -// }, MerchantAccountKycStatusSimplified.OK); - -// return r; -// } - -// export function useInstanceKYCSimplifiedBestStatusLongPolling() { -// const kycStatus = useInstanceKYCDetailsLongPolling(); - -// const allKycData = -// kycStatus !== undefined && -// !(kycStatus instanceof TalerError) && -// kycStatus.type === "ok" -// ? kycStatus.body.kyc_data -// : []; - -// } diff --git a/packages/taler-merchant-webui/src/hooks/instance.ts b/packages/taler-merchant-webui/src/hooks/instance.ts @@ -16,10 +16,18 @@ import { AccessToken, + isOperationFail, + MerchantAccountKycRedirectsResponse, + Result, + succeedOrThrow, TalerHttpError, TalerMerchantManagementResultByMethod, } from "@gnu-taler/taler-util"; -import { LONG_POLL_DELAY, useLongPolling } from "@gnu-taler/web-util/browser"; +import { + delayMs, + LONG_POLL_DELAY, + useLongPolling, +} from "@gnu-taler/web-util/browser"; import { useSessionContext } from "../context/session.js"; // Fix default import https://github.com/microsoft/TypeScript/issues/49189 @@ -66,65 +74,29 @@ export function useInstanceKYCDetailsLongPolling() { const { state, lib } = useSessionContext(); const token = state.token; - async function fetcher([token]: [AccessToken]) { - return await lib.instance.getCurrentInstanceKycStatus(token); - } - - const { data, error, mutate } = useSWR< - TalerMerchantManagementResultByMethod<"getCurrentInstanceKycStatus">, - TalerHttpError - >([token, "getCurrentInstanceKycStatus"], fetcher); - + type Res = Awaited<ReturnType<typeof lib.instance.getCurrentInstanceKycStatus>>; + const result = useLongPolling( - data, - (result) => { - return true; - }, - async (ct, latestData) => { - if ( - latestData === undefined || - latestData.type === "fail" || - !latestData.body.etag - ) - return undefined; + async (ct, latestData): Promise<Res> => { + const etag = latestData?.type === "ok" ? latestData.body.etag : undefined; const r = await lib.instance.getCurrentInstanceKycStatus(token!, { - longpoll: { - type: "state-change", - etag: latestData.body.etag, - timeout: LONG_POLL_DELAY, - }, + longpoll: etag + ? { + type: "state-change", + etag: etag, + timeout: LONG_POLL_DELAY, + } + : undefined, cancellationToken: ct, }); - await mutate(r, { revalidate: false }); return r; }, - [], { minTime: LONG_POLL_DELAY }, ); - - if (error) { - return error; - } + console.log("res", result); return result; } -export function useInstanceKYCDetails() { - const { state, lib } = useSessionContext(); - const token = state.token; - - async function fetcher([token]: [AccessToken]) { - return await lib.instance.getCurrentInstanceKycStatus(token); - } - - const { data, error } = useSWR< - TalerMerchantManagementResultByMethod<"getCurrentInstanceKycStatus">, - TalerHttpError - >([token, "getCurrentInstanceKycStatus"], fetcher); - - if (error) return error; - return data; -} - export function revalidateManagedInstanceDetails() { return mutate( (key) => Array.isArray(key) && key[key.length - 1] === "getInstanceDetails", diff --git a/packages/taler-merchant-webui/src/hooks/order.ts b/packages/taler-merchant-webui/src/hooks/order.ts @@ -66,38 +66,30 @@ export function useOrderDetailsWithLongPoll(orderId: string) { const { state, lib } = useSessionContext(); const token = state.token; - async function fetcher([dId, token]: [string, AccessToken]) { - return await lib.instance.getOrderDetails(token, dId); - } - - const { data, error, mutate } = useSWR< - TalerMerchantManagementResultByMethod<"getOrderDetails">, - TalerHttpError - >([orderId, token, "getOrderDetails"], fetcher); + type Res = + | undefined + | Awaited<ReturnType<typeof lib.instance.getOrderDetails>>; const result = useLongPolling( - data, - (result) => { - return result !== undefined && result.type === "ok"; - }, - async (ct, latestStatus) => { - if (latestStatus === undefined || latestStatus.type === "fail") - return undefined; + async (ct, latestStatus): Promise<Res> => { + const etag = + latestStatus === undefined || latestStatus.type === "fail" + ? undefined + : latestStatus.body.etag; const r = await lib.instance.getOrderDetails(token!, orderId, { - longpoll: { - etag: latestStatus.body.etag!, - timeout: LONG_POLL_DELAY, - }, + longpoll: !etag + ? undefined + : { + etag, + timeout: LONG_POLL_DELAY, + }, cancellationToken: ct, }); - await mutate(r, { revalidate: false }); return r; }, - [orderId], - { minTime: LONG_POLL_DELAY }, + { minTime: LONG_POLL_DELAY, deps: [orderId] }, ); - if (error) return error; return result; } @@ -133,31 +125,14 @@ export function useInstanceOrders(args?: InstanceOrderFilter) { "listOrders", ]; - async function fetcher([pointer, paid, refunded, wired, date]: any) { - return await lib.instance.listOrders(token, { - limit: PAGINATED_LIST_REQUEST, - offset: pointer?.id, - order: pointer?.order, - paid: paid, - refunded: refunded, - wired: wired, - date: date, - }); - } - - const { data, error } = useSWR< - TalerMerchantManagementResultByMethod<"listOrders">, - TalerHttpError - >(cacheKey, fetcher); + type Res = + | undefined + | Awaited<ReturnType<typeof lib.instance.listOrders>>; const result = useLongPolling( - data, - (result) => { - return result !== undefined && result.type === "ok"; - }, - async (ct, latestStatus, args) => { - if (latestStatus === undefined || latestStatus.type === "fail") - return undefined; + async (ct, latestStatus, args): Promise<Res> => { + const noRes = latestStatus === undefined || latestStatus.type === "fail" + const prev = noRes ? [] : latestStatus.body.orders const params: ListOrdersRequestParams = { limit: 1, @@ -170,23 +145,20 @@ export function useInstanceOrders(args?: InstanceOrderFilter) { }; const result = await lib.instance.listOrders(token, { ...params, - timeout: LONG_POLL_DELAY, + timeout: noRes ? undefined : LONG_POLL_DELAY, ct, }); const merged = result.type === "fail" || result.body.orders.length === 0 - ? latestStatus.body.orders - : [...result.body.orders, ...latestStatus.body.orders]; + ? prev + : [...result.body.orders, ...prev]; const newResult = opFixedSuccess(dummyHttpResponse, { orders: merged }); - mutate(newResult, { revalidate: false }); return newResult; }, - cacheKey, - { minTime: LONG_POLL_DELAY }, + { minTime: LONG_POLL_DELAY, deps: cacheKey }, ); - if (error || result instanceof TalerError) return error; if (result === undefined) return undefined; if (result.type !== "ok") return result; diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -2272,6 +2272,23 @@ export interface MerchantAccountKycRedirect { // accounts will do. // Optional. Since protocol **v17**. payto_kycauths?: string[]; + + // Forwarded value of the kyc_swap_tos_acceptance flag + // that the merchant backend observed in the exchange's + // /keys response. Signals to the frontend that it + // should swap the terms-of-service and KYC auth + // authentication steps in the user experience. + // Optional, defaults to false if not given. + // @since protocol **v31**. + kyc_swap_tos_acceptance?: boolean; + + // Taler-Terms-Version (see exchange's /terms + // endpoint) of the terms of service that were already + // accepted by the user for this exchange. Optional, + // absent if no version has been accepted yet by the + // user via POST /private/accept-tos-early. + // @since protocol **v31**. + tos_accepted_early?: string; } export interface ExchangeKycTimeout { @@ -4778,6 +4795,11 @@ export const codecForMerchantAccountKycRedirect = .property("access_token", codecOptional(codecForAccessToken())) .property("limits", codecOptional(codecForList(codecForAccountLimit()))) .property("payto_kycauths", codecOptional(codecForList(codecForString()))) + .property( + "kyc_swap_tos_acceptance", + codecOptionalDefault(codecForBoolean(), false), + ) + .property("tos_accepted_early", codecOptional(codecForString())) .build("TalerMerchantApi.MerchantAccountKycRedirect"); export const codecForTokenScope = codecForEither( diff --git a/packages/web-util/src/hooks/useAsync.ts b/packages/web-util/src/hooks/useAsync.ts @@ -59,7 +59,7 @@ export function useAsync<Res>( } export const LONG_POLL_DELAY = 15000; - +const emptyArray: unknown[] = [] /** * First start with `initial` value, if initial is undefined then finish. * Otherwise: @@ -80,24 +80,58 @@ export const LONG_POLL_DELAY = 15000; * @returns */ export function useLongPolling<Res>( - initial: Res, - shouldRetryFn: (res: Res) => boolean, - retryFn: (ct: CancellationToken, last: Res, deps: Array<any>) => Promise<Res>, - deps: Array<any> = [], - opts: { minTime?: number } = {}, -) { - const minTime = opts?.minTime ?? 1000; - - const [result, setResult] = useState<{ counter: number; prevResult: Res }>({ + retryFn: ( + ct: CancellationToken, + last: undefined | Res, + deps: Array<any>, + ) => Promise<Res>, + opts: { + /** + * Wait time in case the request reply fast. Default LONG_POLL_DELAY + */ + minTime?: number; + /** + * Initial value. Default undefined + */ + initial?: Res; + /** + * Check before any request if loop should stop retrying. Default always false. + */ + shouldStop?: (res: Res) => boolean; + /** + * Dependency array to re-evaluate the state. Default empty. + */ + deps?: Array<unknown>; + /** + * Setting to true will add wait time to the first request. Default false. + * + * Usually first request should return without delay to have the initial data + * and the second request should start next with long poll. + */ + waitFirstRequest?: boolean; + } = {}, +): Res | undefined { + const minTime = opts?.minTime ?? LONG_POLL_DELAY; + const dependencies = (opts?.deps ?? emptyArray) + const [result, setResult] = useState<{ + counter: number; + prevResult: Res | undefined; + }>({ counter: 0, - prevResult: initial, + prevResult: opts.initial, }); function retry(prevResult: Res) { setResult((prev) => ({ counter: prev.counter + 1, prevResult })); } - useEffect(() => { - retry(initial); - }, [initial, ...deps]); + useEffect( + () => { + setResult((prev) => ({ + counter: prev.counter, + prevResult: opts.initial, + })); + }, + [opts.initial, ...dependencies], + ); const ct = useRef<{ ct: CancellationToken.Source | undefined; @@ -109,34 +143,34 @@ export function useLongPolling<Res>( const tk = CancellationToken.create(); ct.current.ct = tk; - const doWeRetry = shouldRetryFn(result.prevResult); + const doWeRetry = + !opts.shouldStop || !result.prevResult + ? true + : !opts.shouldStop(result.prevResult); if (!doWeRetry) return; const diff = new Date().getTime() - ct.current.startMs; - if (ct.current.startMs === 0 || diff > minTime) { + const instant = Promise.resolve(); + const noTryYet = ct.current.startMs === 0; + const enoughTimePassed = diff > minTime; + const firstRequestInstant = result.counter === 1 && !opts?.waitFirstRequest; + + console.log("asd", noTryYet, enoughTimePassed, firstRequestInstant); + (noTryYet || enoughTimePassed || firstRequestInstant + ? instant + : delayMs(minTime - diff) + ).then(() => { + console.log("current", ct.current) + if (ct.current.unloaded) return; ct.current.startMs = new Date().getTime(); - retryFn(tk.token, result.prevResult, deps) + retryFn(tk.token, result.prevResult, dependencies) .then((r) => { if (!tk.token.isCancelled) { retry(r); } }) .catch((error) => console.log("ERROR", error)); - } else { - // calling too fast, wait whats left to reach minTime - delayMs(minTime - diff).then(() => { - if (ct.current.unloaded) return; - ct.current.startMs = new Date().getTime(); - retryFn(tk.token, result.prevResult, deps) - .then((r) => { - if (!tk.token.isCancelled) { - retry(r); - } - }) - .catch((error) => console.log("ERROR", error)); - }); - } - + }); return () => {}; }, [result.counter]); @@ -148,7 +182,7 @@ export function useLongPolling<Res>( ct.current.unloaded = true; ct.current.startMs = 0; }; - }, []); + }, emptyArray); /** * request dependency changed @@ -160,7 +194,7 @@ export function useLongPolling<Res>( ct.current.unloaded = true; ct.current.startMs = 0; }; - }, deps); + }, dependencies); return result.prevResult; }