taler-typescript-core

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

commit eb964dfae0a12f9a90eb066d610f627538f8997c
parent 9d0fc80a905e02a0a0b63dd547daac6e7b17fb52
Author: Christian Blättler <blatc2@bfh.ch>
Date:   Thu, 13 Jun 2024 11:35:52 +0200

Merge branch 'master' into feature/tokens

Diffstat:
Mpackages/aml-backoffice-ui/package.json | 2+-
Mpackages/anastasis-cli/package.json | 2+-
Mpackages/anastasis-core/package.json | 2+-
Mpackages/anastasis-core/src/index.ts | 33+++++++++++++++++++--------------
Mpackages/anastasis-webui/package.json | 2+-
Mpackages/auditor-backoffice-ui/package.json | 2+-
Mpackages/bank-ui/package.json | 2+-
Mpackages/bank-ui/src/hooks/preferences.ts | 11-----------
Mpackages/bank-ui/src/pages/OperationState/state.ts | 24+++++++++++++++++-------
Mpackages/bank-ui/src/pages/PaytoWireTransferForm.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mpackages/bank-ui/src/pages/QrCodeSection.tsx | 8++++----
Mpackages/bank-ui/src/pages/WalletWithdrawForm.tsx | 31++++++++++++++++++++-----------
Mpackages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx | 23+++++++++++++++++++++++
Mpackages/bank-ui/src/pages/account/ShowAccountDetails.tsx | 41++++++++++++++++-------------------------
Mpackages/bank-ui/src/settings.json | 2++
Mpackages/bank-ui/src/settings.ts | 16++++++++++++++++
Mpackages/challenger-ui/package.json | 2+-
Mpackages/idb-bridge/package.json | 4++--
Mpackages/merchant-backend-ui/package.json | 2+-
Mpackages/merchant-backoffice-ui/package.json | 2+-
Mpackages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx | 79++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx | 16----------------
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 4++--
Mpackages/merchant-backoffice-ui/src/components/modal/index.tsx | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx | 30+++++++++++++++++++++++++++++-
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx | 126++++++++++++++++++++++++++++++-------------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx | 11+----------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx | 36++++++++++++++++++++----------------
Mpackages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx | 1-
Mpackages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx | 1-
Mpackages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx | 2+-
Mpackages/pogen/package.json | 2+-
Mpackages/taler-harness/debian/changelog | 24++++++++++++++++++++++++
Mpackages/taler-harness/package.json | 2+-
Mpackages/taler-harness/src/harness/harness.ts | 4++++
Mpackages/taler-harness/src/harness/helpers.ts | 5++++-
Mpackages/taler-harness/src/integrationtests/test-currency-scope.ts | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-harness/src/integrationtests/test-multiexchange.ts | 69++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-harness/src/integrationtests/test-payment-template.ts | 4+++-
Mpackages/taler-harness/src/integrationtests/test-refund-auto.ts | 166++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts | 51+++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts | 98++++++++-----------------------------------------------------------------------
Mpackages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts | 43++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts | 83++++++++++++++-----------------------------------------------------------------
Mpackages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts | 21++++++++++++++-------
Mpackages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts | 50++++++++++++++++++++++++++++++++++----------------
Mpackages/taler-harness/src/integrationtests/test-withdrawal-fees.ts | 25+++++++++++++++----------
Apackages/taler-harness/src/integrationtests/test-withdrawal-flex.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 4+++-
Mpackages/taler-util/package.json | 2+-
Mpackages/taler-util/src/CancellationToken.ts | 2+-
Mpackages/taler-util/src/bank-api-client.ts | 2+-
Mpackages/taler-util/src/errors.ts | 5+++++
Mpackages/taler-util/src/http-client/bank-integration.ts | 8+++++++-
Mpackages/taler-util/src/http-client/bank-revenue.ts | 4++++
Mpackages/taler-util/src/http-client/types.ts | 141+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/taler-util/src/http-impl.qtart.ts | 6+++++-
Mpackages/taler-util/src/invariants.ts | 2+-
Mpackages/taler-util/src/notifications.ts | 2+-
Mpackages/taler-util/src/payto.ts | 20+++++++++++++++++++-
Mpackages/taler-util/src/qtart.ts | 5++++-
Mpackages/taler-util/src/taler-error-codes.ts | 148+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/taler-util/src/taler-types.ts | 4++--
Mpackages/taler-util/src/transactions-types.ts | 7+++++--
Mpackages/taler-util/src/wallet-types.ts | 54+++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-cli/debian/changelog | 24++++++++++++++++++++++++
Mpackages/taler-wallet-cli/package.json | 2+-
Mpackages/taler-wallet-cli/src/index.ts | 10++++++++++
Mpackages/taler-wallet-core/package.json | 2+-
Mpackages/taler-wallet-core/src/backup/index.ts | 16++++++++++++----
Mpackages/taler-wallet-core/src/balance.ts | 20++++++++++++++++----
Mpackages/taler-wallet-core/src/coinSelection.ts | 5+++--
Mpackages/taler-wallet-core/src/common.ts | 92++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/taler-wallet-core/src/db.ts | 126+++++++++++++++++--------------------------------------------------------------
Mpackages/taler-wallet-core/src/dbless.ts | 2+-
Mpackages/taler-wallet-core/src/deposits.ts | 26+++++++++++++++++++++-----
Mpackages/taler-wallet-core/src/exchanges.ts | 348+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/taler-wallet-core/src/instructedAmountConversion.ts | 22++++++++++++++++------
Mpackages/taler-wallet-core/src/pay-merchant.ts | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/taler-wallet-core/src/pay-peer-common.ts | 6+++---
Mpackages/taler-wallet-core/src/pay-peer-pull-credit.ts | 49++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/taler-wallet-core/src/pay-peer-pull-debit.ts | 2+-
Mpackages/taler-wallet-core/src/pay-peer-push-credit.ts | 35+++++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/pay-peer-push-debit.ts | 35+++++++++++++++++++++++++++++------
Mpackages/taler-wallet-core/src/recoup.ts | 4++--
Mpackages/taler-wallet-core/src/refresh.ts | 143++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/taler-wallet-core/src/shepherd.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-wallet-core/src/transactions.ts | 117+++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-wallet-core/src/versions.ts | 9+--------
Mpackages/taler-wallet-core/src/wallet-api-types.ts | 13++++++++++++-
Mpackages/taler-wallet-core/src/wallet.ts | 84++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/taler-wallet-core/src/withdraw.ts | 590+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/taler-wallet-embedded/package.json | 2+-
Apackages/taler-wallet-embedded/src/wallet-qjs-tests.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-embedded/src/wallet-qjs.ts | 209++++++++++++++++++++-----------------------------------------------------------
Mpackages/taler-wallet-webextension/manifest-common.json | 4++--
Mpackages/taler-wallet-webextension/package.json | 2+-
Mpackages/taler-wallet-webextension/src/components/HistoryItem.tsx | 4+++-
Mpackages/taler-wallet-webextension/src/components/WalletActivity.tsx | 6++++--
Mpackages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx | 21++++++++++++++++-----
Mpackages/taler-wallet-webextension/src/cta/Payment/views.tsx | 32+++++++++++++++++++++-----------
Mpackages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts | 105++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx | 24++++++------------------
Mpackages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx | 22+++++++++++++++-------
Mpackages/taler-wallet-webextension/src/cta/Withdraw/index.ts | 22++++++++++++++--------
Mpackages/taler-wallet-webextension/src/cta/Withdraw/state.ts | 203++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx | 178+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackages/taler-wallet-webextension/src/cta/Withdraw/test.ts | 41+++++++++++++++++++++++++++++++----------
Mpackages/taler-wallet-webextension/src/cta/Withdraw/views.tsx | 28+++++++++-------------------
Mpackages/taler-wallet-webextension/src/hooks/useIsOnline.ts | 20+++++++++++++++++---
Mpackages/taler-wallet-webextension/src/platform/chrome.ts | 30+++++++++++++++++++++++++-----
Mpackages/taler-wallet-webextension/src/platform/dev.ts | 20++------------------
Mpackages/taler-wallet-webextension/src/popup/BalancePage.tsx | 2+-
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/index.ts | 2+-
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/state.ts | 68++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx | 15++++++++++++---
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/test.ts | 57+++++++++++++++++++++++----------------------------------
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx | 13++++---------
Mpackages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx | 15+++------------
Mpackages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx | 1-
Mpackages/taler-wallet-webextension/src/wallet/Transaction.tsx | 12++++++++++++
Mpackages/taler-wallet-webextension/src/wxApi.ts | 2+-
Mpackages/taler-wallet-webextension/src/wxBackend.ts | 150++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/web-util/package.json | 2+-
Mpackages/web-util/src/components/CopyButton.tsx | 2+-
Mpackages/web-util/src/index.build.ts | 6++++--
Mpnpm-lock.yaml | 955+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
130 files changed, 4029 insertions(+), 2199 deletions(-)

diff --git a/packages/aml-backoffice-ui/package.json b/packages/aml-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/aml-backoffice-ui", - "version": "0.10.7", + "version": "0.11.4", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "Back-office SPA for GNU Taler Exchange.", diff --git a/packages/anastasis-cli/package.json b/packages/anastasis-cli/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/anastasis-cli", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/anastasis-core", - "version": "0.10.7", + "version": "0.11.4", "description": "", "main": "./lib/index.js", "module": "./lib/index.js", diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts @@ -43,7 +43,7 @@ import { URL, j2s, } from "@gnu-taler/taler-util"; -import { HttpResponse } from "@gnu-taler/taler-util/http"; +import { HttpResponse, createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { anastasisData } from "./anastasis-data.js"; import { codecForChallengeInstructionMessage, @@ -139,6 +139,11 @@ export * from "./challenge-feedback-types.js"; const logger = new Logger("anastasis-core:index.ts"); +const http = createPlatformHttpLib({ + enableThrottling: true, + requireTls: false, +}); + const ANASTASIS_HTTP_HEADER_POLICY_META_DATA = "Anastasis-Policy-Meta-Data"; function getContinents(): ContinentInfo[] { @@ -279,9 +284,9 @@ async function getProviderInfo( providerBaseUrl: string, ): Promise<AuthenticationProviderStatus> { // FIXME: Use a reasonable timeout here. - let resp: Response; + let resp: HttpResponse; try { - resp = await fetch(new URL("config", providerBaseUrl).href); + resp = await http.fetch(new URL("config", providerBaseUrl).href); } catch (e) { console.warn( "Encountered an HTTP error whilst trying to get the provider's config: ", @@ -293,7 +298,7 @@ async function getProviderInfo( hint: "request to anastasis provider failed", }; } - if (!resp.ok) { + if (resp.status < 200 || resp.status > 299) { console.warn("Got bad response code whilst getting provider config", resp); return { status: "error", @@ -556,7 +561,7 @@ async function uploadSecret( // FIXME: Get this from the params reqUrl.searchParams.set("timeout_ms", "500"); } - const resp = await fetch(reqUrl.href, { + const resp = await http.fetch(reqUrl.href, { method: "POST", headers: { "content-type": "application/json", @@ -646,7 +651,7 @@ async function uploadSecret( reqUrl.searchParams.set("timeout_ms", "500"); } logger.info(`uploading policy to ${prov.provider_url}`); - const resp = await fetch(reqUrl.href, { + const resp = await http.fetch(reqUrl.href, { method: "POST", headers: { "Anastasis-Policy-Signature": encodeCrock(sig), @@ -757,14 +762,14 @@ async function downloadPolicyFromProvider( const acctKeypair = accountKeypairDerive(userId); const reqUrl = new URL(`policy/${acctKeypair.pub}`, providerUrl); reqUrl.searchParams.set("version", `${version}`); - const resp = await fetch(reqUrl.href); + const resp = await http.fetch(reqUrl.href); if (resp.status !== 200) { logger.info( `Could not download policy from provider ${providerUrl}, status ${resp.status}`, ); return undefined; } - const body = await resp.arrayBuffer(); + const body = await resp.bytes(); const bodyDecrypted = await decryptRecoveryDocument( userId, encodeCrock(body), @@ -981,10 +986,10 @@ async function requestTruth( const hresp = await getResponseHash(truth, solveRequest); - let resp: Response; + let resp: HttpResponse; try { - resp = await fetch(url.href, { + resp = await http.fetch(url.href, { method: "POST", headers: { Accept: "application/json", @@ -1022,7 +1027,7 @@ async function requestTruth( truth.provider_salt, ); - const respBody = new Uint8Array(await resp.arrayBuffer()); + const respBody = new Uint8Array(await resp.bytes()); const keyShare = await decryptKeyShare( encodeCrock(respBody), userId, @@ -1138,10 +1143,10 @@ async function selectChallenge( } } - let resp: Response; + let resp: HttpResponse; try { - resp = await fetch(url.href, { + resp = await http.fetch(url.href, { method: "POST", headers: { Accept: "application/json", @@ -1859,7 +1864,7 @@ export async function discoverPolicies( ); const acctKeypair = accountKeypairDerive(userId); const reqUrl = new URL(`policy/${acctKeypair.pub}/meta`, providerUrl); - const resp = await fetch(reqUrl.href); + const resp = await http.fetch(reqUrl.href); if (resp.status !== 200) { logger.warn(`Could not fetch policy metadate from ${reqUrl.href}`); continue; diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/anastasis-webui", - "version": "0.10.7", + "version": "0.11.4", "license": "MIT", "type": "module", "scripts": { diff --git a/packages/auditor-backoffice-ui/package.json b/packages/auditor-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/auditor-backoffice-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/packages/bank-ui/package.json b/packages/bank-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/bank-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-OR-LATER", "type": "module", "scripts": { diff --git a/packages/bank-ui/src/hooks/preferences.ts b/packages/bank-ui/src/hooks/preferences.ts @@ -31,8 +31,6 @@ interface Preferences { showWithdrawalSuccess: boolean; showDemoDescription: boolean; showInstallWallet: boolean; - maxWithdrawalAmount: number; - fastWithdrawal: boolean; showDebugInfo: boolean; } @@ -41,17 +39,13 @@ export const codecForPreferences = (): Codec<Preferences> => .property("showWithdrawalSuccess", codecForBoolean()) .property("showDemoDescription", codecForBoolean()) .property("showInstallWallet", codecForBoolean()) - .property("fastWithdrawal", codecForBoolean()) .property("showDebugInfo", codecForBoolean()) - .property("maxWithdrawalAmount", codecForNumber()) .build("Settings"); const defaultPreferences: Preferences = { showWithdrawalSuccess: true, showDemoDescription: true, showInstallWallet: true, - maxWithdrawalAmount: 25, - fastWithdrawal: false, showDebugInfo: false, }; @@ -82,7 +76,6 @@ export function usePreferences(): [ export function getAllBooleanPreferences(): Array<keyof Preferences> { return [ - "fastWithdrawal", "showDebugInfo", "showDemoDescription", "showInstallWallet", @@ -95,16 +88,12 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { - case "maxWithdrawalAmount": - return i18n.str`Max withdrawal amount`; case "showWithdrawalSuccess": return i18n.str`Show withdrawal confirmation`; case "showDemoDescription": return i18n.str`Show demo description`; case "showInstallWallet": return i18n.str`Show install wallet first`; - case "fastWithdrawal": - return i18n.str`Use fast withdrawal form`; case "showDebugInfo": return i18n.str`Show debug info`; } diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts @@ -18,6 +18,7 @@ import { Amounts, HttpStatusCode, TalerCoreBankErrorsByMethod, + TalerCorebankApi, TalerError, assertUnreachable, parsePaytoUri, @@ -33,6 +34,7 @@ import { useSessionState } from "../../hooks/session.js"; import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { Props, State } from "./index.js"; +import { useSettingsContext } from "../../context/settings.js"; export function useComponentState({ currency, @@ -41,7 +43,8 @@ export function useComponentState({ routeHere, onAuthorizationRequired, }: Props): utils.RecursiveState<State> { - const [settings] = usePreferences(); + const [preference] = usePreferences(); + const settings = useSettingsContext(); const [bankState, updateBankState] = useBankState(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; @@ -52,15 +55,22 @@ export function useComponentState({ const [failure, setFailure] = useState< TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined >(); - const amount = settings.maxWithdrawalAmount; + const amount = settings.defaultSuggestedAmount; async function doSilentStart() { // FIXME: if amount is not enough use balance const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`); if (!creds) return; - const resp = await bank.createWithdrawal(creds, { - amount: Amounts.stringify(parsedAmount), - }); + const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = + settings.fastWithdrawalForm + ? { + suggested_amount: Amounts.stringify(parsedAmount), + } + : { + amount: Amounts.stringify(parsedAmount), + }; + + const resp = await bank.createWithdrawal(creds, params); if (resp.type === "fail") { setFailure(resp); return; @@ -73,7 +83,7 @@ export function useComponentState({ if (withdrawalOperationId === undefined) { doSilentStart(); } - }, [settings.fastWithdrawal, amount]); + }, [settings.fastWithdrawalForm, amount]); if (failure) { return { @@ -174,7 +184,7 @@ export function useComponentState({ } if (data.status === "confirmed") { - if (!settings.showWithdrawalSuccess) { + if (!preference.showWithdrawalSuccess) { updateBankState("currentWithdrawalOperationId", undefined); // onClose() } diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -79,6 +79,7 @@ export function PaytoWireTransferForm({ routeHere, onAuthorizationRequired, limit, + balance, }: Props): VNode { const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form"); const isRawPayto = inputType !== "form"; @@ -111,6 +112,16 @@ export function PaytoWireTransferForm({ ? ("x-taler-bank" as const) : ("iban" as const); + const wireFee = + config.wire_transfer_fees === undefined + ? Amounts.zeroOfCurrency(config.currency) + : Amounts.parseOrThrow(config.wire_transfer_fees); + + const limitWithFee = + Amounts.cmp(limit, wireFee) === 1 + ? Amounts.sub(limit, wireFee).amount + : Amounts.zeroOfAmount(limit); + const errorsWire = undefinedIfEmpty({ account: !account ? i18n.str`Required` @@ -124,7 +135,7 @@ export function PaytoWireTransferForm({ ? i18n.str`Required` : !parsedAmount ? i18n.str`Not valid` - : validateAmount(parsedAmount, limit, i18n), + : validateAmount(parsedAmount, limitWithFee, i18n), }); const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); @@ -134,7 +145,7 @@ export function PaytoWireTransferForm({ ? i18n.str`Required` : !parsed ? i18n.str`Does not follow the pattern` - : validateRawPayto(parsed, limit, url.host, i18n, paytoType), + : validateRawPayto(parsed, limitWithFee, url.host, i18n, paytoType), }); async function doSend() { @@ -479,9 +490,9 @@ export function PaytoWireTransferForm({ e.preventDefault(); }} > - <div class="p-4 sm:p-8"> + <div class="m-4"> {!isRawPayto ? ( - <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 "> {(() => { switch (paytoType) { case "x-taler-bank": { @@ -622,7 +633,45 @@ export function PaytoWireTransferForm({ </div> </div> )} + {Amounts.cmp(limitWithFee, balance) > 0 ? ( + <p class="mt-2 text-sm text-gray-900"> + <i18n.Translate> + You can transfer{" "} + <RenderAmount + value={limitWithFee} + spec={config.currency_specification} + /> + </i18n.Translate> + </p> + ) : undefined} </div> + {Amounts.isZero(wireFee) ? undefined : ( + <div class="px-4 my-4"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-6"> + <dl class="mt-4 space-y-4"> + <Fragment> + <div class="flex items-center justify-between "> + <dt class="flex items-center text-sm text-gray-600"> + <span> + <i18n.Translate>Cost</i18n.Translate> + </span> + </dt> + <dd class="text-sm text-gray-900"> + <RenderAmount + value={wireFee} + negative + withColor + spec={config.currency_specification} + /> + </dd> + </div> + </Fragment> + </dl> + </div> + </div> + </div> + )} <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> {routeCancel ? ( <a diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -86,10 +86,10 @@ export function QrCodeSection({ <div class="mt-4 mb-4 text-sm text-gray-500"> <p> <i18n.Translate> - You will see the details of the operation in your wallet - including the fees (if applies). If you still don't have one you - can install it following instructions in - </i18n.Translate>{" "} + Your wallet will display the details of the transaction + including the fees (if applicable). If you do not yet have a + wallet, please follow the instructions on + </i18n.Translate> <a class="font-semibold text-gray-500 hover:text-gray-400" name="wallet page" diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -19,6 +19,7 @@ import { AmountJson, Amounts, HttpStatusCode, + TalerCorebankApi, TranslatedString, assertUnreachable, parseWithdrawUri, @@ -45,6 +46,7 @@ import { RenderAmount, doAutoFocus, } from "./PaytoWireTransferForm.js"; +import { useSettingsContext } from "../context/settings.js"; const RefAmount = forwardRef(InputAmount); @@ -64,7 +66,7 @@ function OldWithdrawalForm({ routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const [settings] = usePreferences(); + const settings = useSettingsContext(); // const walletInegrationApi = useTalerWalletIntegrationAPI() // const { navigateTo } = useNavigationContext(); @@ -79,7 +81,7 @@ function OldWithdrawalForm({ const creds = credentials.status !== "loggedIn" ? undefined : credentials; const [amountStr, setAmountStr] = useState<string | undefined>( - `${settings.maxWithdrawalAmount}`, + `${settings.defaultSuggestedAmount ?? 1}`, ); const [notification, notify, handleError] = useLocalNotification(); @@ -141,9 +143,15 @@ function OldWithdrawalForm({ async function doStart() { if (!parsedAmount || !creds) return; await handleError(async () => { - const resp = await api.createWithdrawal(creds, { - amount: Amounts.stringify(parsedAmount), - }); + const params: TalerCorebankApi.BankAccountCreateWithdrawalRequest = + settings.fastWithdrawalForm + ? { + suggested_amount: Amounts.stringify(parsedAmount), + } + : { + amount: Amounts.stringify(parsedAmount), + }; + const resp = await api.createWithdrawal(creds, params); if (resp.type === "ok") { const uri = parseWithdrawUri(resp.body.taler_withdraw_uri); if (!uri) { @@ -234,9 +242,9 @@ function OldWithdrawalForm({ </i18n.Translate> </p> {Amounts.cmp(limit, balance) > 0 ? ( - <p class="mt-2 text-sm text-gray-500"> + <p class="mt-2 text-sm text-gray-900"> <i18n.Translate> - Your account allows you to withdraw{" "} + You can withdraw{" "} <RenderAmount value={limit} spec={config.currency_specification} @@ -340,7 +348,8 @@ export function WalletWithdrawForm({ routeCancel: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); - const [settings, updateSettings] = usePreferences(); + const [pref, updatePref] = usePreferences(); + const settings = useSettingsContext(); return ( <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> @@ -357,11 +366,11 @@ export function WalletWithdrawForm({ </div> <div class="col-span-2"> - {settings.showInstallWallet && ( + {pref.showInstallWallet && ( <Attention title={i18n.str`You need a Taler wallet`} onClose={() => { - updateSettings("showInstallWallet", false); + updatePref("showInstallWallet", false); }} > <i18n.Translate> @@ -379,7 +388,7 @@ export function WalletWithdrawForm({ </Attention> )} - {!settings.fastWithdrawal ? ( + {!settings.fastWithdrawalForm ? ( <OldWithdrawalForm focus={focus} routeOperationDetails={routeOperationDetails} diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -17,6 +17,7 @@ import { AbsoluteTime, AmountJson, + Amounts, HttpStatusCode, PaytoUri, PaytoUriIBAN, @@ -79,6 +80,11 @@ export function WithdrawalConfirmationQuestion({ lib: { bank: api }, } = useBankCoreApiContext(); + const wireFee = + config.wire_transfer_fees === undefined + ? Amounts.zeroOfCurrency(config.currency) + : Amounts.parseOrThrow(config.wire_transfer_fees); + async function doTransfer() { await handleError(async () => { if (!creds) return; @@ -357,6 +363,23 @@ export function WithdrawalConfirmationQuestion({ /> </dd> </div> + {Amounts.isZero(wireFee) ? undefined : ( + <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Cost</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount + value={wireFee} + negative + withColor + spec={config.currency_specification} + /> + </dd> + </div> + </Fragment> + )} </dl> </div> </div> diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -15,6 +15,7 @@ */ import { AbsoluteTime, + AccountLetter, HttpStatusCode, TalerCorebankApi, TalerError, @@ -200,28 +201,17 @@ export function ShowAccountDetails({ } const url = bank.getRevenueAPI(account); - url.username = account; const baseURL = url.href; - + const revenueURL = new URL(baseURL) + revenueURL.username = account; + revenueURL.password = creds?.token ?? "" const ac = parsePaytoUri(result.body.payto_uri); const payto = !ac?.isKnown ? undefined : ac; - let accountLetter: string | undefined = undefined; - if (payto) { - switch (payto.targetType) { - case "iban": { - accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\niban=${payto.iban}\nreceiver-name=${result.body.name}\n`; - break; - } - case "x-taler-bank": { - accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naccount=${payto.account}\nhost=${payto.host}\nreceiver-name=${result.body.name}\n`; - break; - } - case "bitcoin": { - accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naddress=${payto.address}\nreceiver-name=${result.body.name}\n`; - break; - } + const accountLetter : AccountLetter | undefined = !payto + ? undefined + : { + accountURI: result.body.payto_uri, infoURL: revenueURL.href } - } return ( <Fragment> @@ -327,7 +317,7 @@ export function ShowAccountDetails({ name="account-type" id="account-type" disabled={true} - value={account} + value={payto.targetType} autocomplete="off" /> </div> @@ -372,16 +362,16 @@ export function ShowAccountDetails({ <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" - for="iban" + for="account-name" > - {i18n.str`IBAN`} + {i18n.str`Account name`} </label> <div class="mt-2"> <input type="text" class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" + name="account-name" + id="account-name" disabled={true} value={payto.account} autocomplete="off" @@ -389,7 +379,7 @@ export function ShowAccountDetails({ </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - International Bank Account Number. + Bank account identifier for wire transfers. </i18n.Translate> </p> </div> @@ -486,7 +476,7 @@ export function ShowAccountDetails({ <i18n.Translate>Cancel</i18n.Translate> </a> <CopyButton - getContent={() => accountLetter ?? ""} + getContent={() => !accountLetter ? "" : JSON.stringify(accountLetter)} class="flex text-center disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > <i18n.Translate>Copy</i18n.Translate> @@ -498,3 +488,4 @@ export function ShowAccountDetails({ </Fragment> ); } + diff --git a/packages/bank-ui/src/settings.json b/packages/bank-ui/src/settings.json @@ -2,6 +2,8 @@ "backendBaseURL": "http://bank.taler.test:1180/", "simplePasswordForRandomAccounts": true, "allowRandomAccountCreation": true, + "fastWithdrawalForm": true, + "defaultSuggestedAmount": 11, "bankName": "Taler DEVELOPMENT Bank", "topNavSites": { "Exchange": "http://Exchnage.taler.test:1180/", diff --git a/packages/bank-ui/src/settings.ts b/packages/bank-ui/src/settings.ts @@ -20,6 +20,7 @@ import { canonicalizeBaseUrl, codecForBoolean, codecForMap, + codecForNumber, codecForString, codecOptional, } from "@gnu-taler/taler-util"; @@ -45,6 +46,17 @@ export interface UiSettings { // - value: link target, where the user is going to be redirected // default: empty list topNavSites?: Record<string, string>; + // Use the withdrawal form which redirect the user to the wallet + // without asking the amount to the user. + // - true: on withdrawal creation the spa will use suggested_amount instead + // of fixed amount + // - false: on withdrawal creation the spa will use fixed amount + // default: false + fastWithdrawalForm?: boolean; + // When the withdrawal form use the suggested amount the bank + // will send a default value that the user can change. + // default: 10 + defaultSuggestedAmount?: number; } /** @@ -56,12 +68,16 @@ const defaultSettings: UiSettings = { simplePasswordForRandomAccounts: false, allowRandomAccountCreation: false, topNavSites: {}, + fastWithdrawalForm: false, + defaultSuggestedAmount: 10, }; const codecForUISettings = (): Codec<UiSettings> => buildCodecForObject<UiSettings>() .property("backendBaseURL", codecOptional(codecForString())) .property("allowRandomAccountCreation", codecOptional(codecForBoolean())) + .property("fastWithdrawalForm", codecOptional(codecForBoolean())) + .property("defaultSuggestedAmount", codecOptional(codecForNumber())) .property( "simplePasswordForRandomAccounts", codecOptional(codecForBoolean()), diff --git a/packages/challenger-ui/package.json b/packages/challenger-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/challenger-ui", - "version": "0.10.7", + "version": "0.11.4", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "UI for GNU Challenger.", diff --git a/packages/idb-bridge/package.json b/packages/idb-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/idb-bridge", - "version": "0.10.7", + "version": "0.11.4", "description": "IndexedDB implementation that uses SQLite3 as storage", "main": "./dist/idb-bridge.js", "module": "./lib/index.js", @@ -38,6 +38,6 @@ "failFast": true }, "optionalDependencies": { - "better-sqlite3": "9.4.0" + "better-sqlite3": "10.0.0" } } diff --git a/packages/merchant-backend-ui/package.json b/packages/merchant-backend-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/merchant-backend-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-or-later", "scripts": { "compile": "tsc && ./build.mjs", diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "@gnu-taler/merchant-backoffice-ui", - "version": "0.10.7", + "version": "0.11.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -18,13 +18,10 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { - parsePaytoUri, - PaytoUriGeneric, - stringifyPaytoUri, -} from "@gnu-taler/taler-util"; +import { parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; import { COUNTRY_TABLE } from "../../utils/constants.js"; import { undefinedIfEmpty } from "../../utils/table.js"; import { FormErrors, FormProvider } from "./FormProvider.js"; @@ -32,7 +29,6 @@ import { Input } from "./Input.js"; import { InputGroup } from "./InputGroup.js"; import { InputSelector } from "./InputSelector.js"; import { InputProps, useField } from "./useField.js"; -import { useEffect, useState } from "preact/hooks"; export interface Props<T> extends InputProps<T> { isValid?: (e: any) => boolean; @@ -108,13 +104,13 @@ function validateEthereum_path1( * bank.com/path * bank.com/path/subpath/ */ -const DOMAIN_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9](?:\.[a-zA-Z]{2,})+(\/[a-zA-Z0-9-.]+)*\/?$/ +const DOMAIN_REGEX = + /^[a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9-_](?:\.[a-zA-Z0-9-_]{2,})+(:[0-9]+)?(\/[a-zA-Z0-9-.]+)*\/?$/; function validateTalerBank_path1( addr: string, i18n: ReturnType<typeof useTranslationContext>["i18n"], ): string | undefined { - console.log(addr, DOMAIN_REGEX.test(addr)) try { const valid = DOMAIN_REGEX.test(addr); if (valid) return undefined; @@ -206,6 +202,7 @@ export function InputPaytoForm<T>({ const { value: initialValueStr, onChange } = useField<T>(name); const initialPayto = parsePaytoUri(initialValueStr ?? ""); + const paths = !initialPayto ? [] : initialPayto.targetPath.split("/"); const initialPath1 = paths.length >= 1 ? paths[0] : undefined; const initialPath2 = paths.length >= 2 ? paths[1] : undefined; @@ -219,6 +216,22 @@ export function InputPaytoForm<T>({ path2: initialPath2, }; const [value, setValue] = useState<Partial<Entity>>(initial); + useEffect(() => { + const nv = parsePaytoUri(initialValueStr ?? ""); + const paths = !initialPayto ? [] : initialPayto.targetPath.split("/"); + if (nv !== undefined && nv.isKnown) { + if (nv.targetType === "iban" && paths.length >= 2) { + //FIXME: workaround EBIC not supported + paths[0] = paths[1] + } + setValue({ + target: nv.targetType, + params: nv.params, + path1: paths.length >= 1 ? paths[0] : undefined, + path2: paths.length >= 2 ? paths[1] : undefined, + }); + } + }, [initialValueStr]); const { i18n } = useTranslationContext(); @@ -252,7 +265,8 @@ export function InputPaytoForm<T>({ (k) => (errors as any)[k] !== undefined, ); - const path1WithSlash = value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1 + const path1WithSlash = + value.path1 && !value.path1.endsWith("/") ? value.path1 + "/" : value.path1; const str = hasErrors || !value.target ? undefined @@ -268,37 +282,6 @@ export function InputPaytoForm<T>({ onChange(str as any); }, [str]); - // const submit = useCallback((): void => { - // // const accounts: TalerMerchantApi.AccountAddDetails[] = paytos; - // // const alreadyExists = - // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; - // // if (!alreadyExists) { - // const newValue: TalerMerchantApi.AccountAddDetails = { - // payto_uri: paytoURL, - // }; - // if (value.auth) { - // if (value.auth.url) { - // newValue.credit_facade_url = value.auth.url; - // } - // if (value.auth.type === "none") { - // newValue.credit_facade_credentials = { - // type: "none", - // }; - // } - // if (value.auth.type === "basic") { - // newValue.credit_facade_credentials = { - // type: "basic", - // username: value.auth.username ?? "", - // password: value.auth.password ?? "", - // }; - // } - // } - // onChange(newValue as any); - // // } - // // valueHandler(defaultTarget); - // }, [value]); - - //FIXME: translating plural singular return ( <InputGroup name="payto" label={label} fixed tooltip={tooltip}> <FormProvider<Entity> @@ -413,11 +396,17 @@ export function InputPaytoForm<T>({ return v; }} tooltip={i18n.str`Bank host.`} - help={<Fragment> - <div><i18n.Translate>Without scheme and may include subpath:</i18n.Translate></div> - <div>bank.com/</div> - <div>bank.com/path/subpath/</div> - </Fragment>} + help={ + <Fragment> + <div> + <i18n.Translate> + Without scheme and may include subpath: + </i18n.Translate> + </div> + <div>bank.com/</div> + <div>bank.com/path/subpath/</div> + </Fragment> + } /> <Input<Entity> name="path2" diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -60,22 +60,6 @@ export function DefaultInstanceFormFields({ tooltip={i18n.str`Legal name of the business represented by this instance.`} /> - <TextField name="asdasd" label=""> - <i18n.Translate> - Choose individual if you don't have or are not required to have legal business permission. - </i18n.Translate> - </TextField> - - <InputSelector<Entity> - name="user_type" - label={i18n.str`Selling as`} - tooltip={i18n.str`Different type of account can have different rules and requirements.`} - values={["business", "individual"]} - toStr={(d: string) => { - return d.toUpperCase(); - }} - /> - <Input<Entity> name="email" label={i18n.str`Email`} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -111,7 +111,7 @@ export function Sidebar({ mobile }: Props): VNode { <li> <a href={"/templates"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-qrcode" /> </span> <span class="menu-item-label"> <i18n.Translate>Templates</i18n.Translate> @@ -166,7 +166,7 @@ export function Sidebar({ mobile }: Props): VNode { <li> <a href={"/webhooks"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-webhook" /> </span> <span class="menu-item-label"> <i18n.Translate>Webhooks</i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -24,9 +24,14 @@ import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; import { Spinner } from "../exception/loading.js"; -import { FormProvider } from "../form/FormProvider.js"; +import { FormErrors, FormProvider } from "../form/FormProvider.js"; import { Input } from "../form/Input.js"; import { useSessionContext } from "../../context/session.js"; +import { + AccountLetter, + codecForAccountLetter, + PaytoString, +} from "@gnu-taler/taler-util"; interface Props { active?: boolean; @@ -201,6 +206,88 @@ export function ClearConfirmModal({ ); } +interface ImportingAccountModalProps { + onCancel: () => void; + onConfirm: (account: AccountLetter) => void; +} + +export function ImportingAccountModal({ + onCancel, + onConfirm, +}: ImportingAccountModalProps): VNode { + const { i18n } = useTranslationContext(); + const [letter, setLetter] = useState<string>(); + let parsed = undefined; + try { + parsed = JSON.parse(letter ?? ""); + } catch (e) { + parsed = undefined; + } + let account: AccountLetter | undefined = undefined; + let parsingError: string | undefined = undefined; + try { + account = + parsed !== undefined ? codecForAccountLetter().decode(parsed) : undefined; + } catch (e) { + account = undefined; + if (e instanceof Error) { + parsingError = e.message; + } + } + const errors: FormErrors<{ letter: string }> = { + letter: !letter + ? i18n.str`required` + : parsed === undefined + ? i18n.str`letter should be a JSON string` + : account === undefined + ? i18n.str`JSON string is invalid` + : undefined, + }; + return ( + <ConfirmModal + label={i18n.str`Import`} + description={i18n.str`Importing an account from the bank`} + active + onCancel={onCancel} + disabled={account === undefined} + onConfirm={() => onConfirm(account!)} + > + <p> + <i18n.Translate> + You can export your account settings from the Libeufin Bank's account + profile. Paste the content in the next field. + </i18n.Translate> + </p> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Account information</i18n.Translate> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input + class="input" + value={letter ?? ""} + onChange={(e) => { + setLetter(e.currentTarget.value); + }} + /> + </p> + {letter !== undefined && errors.letter && ( + <p class="help is-danger">{errors.letter}</p> + )} + {parsingError !== undefined && ( + <p class="help is-danger">{parsingError}</p> + )} + </div> + </div> + </div> + </ConfirmModal> + ); +} + interface DeleteModalProps { element: { id: string; name: string }; onCancel: () => void; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx @@ -31,6 +31,7 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { ImportingAccountModal } from "../../../../components/modal/index.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { safeConvertURL } from "../update/UpdatePage.js"; @@ -46,6 +47,7 @@ const accountAuthType = ["none", "basic"]; export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); + const [importing, setImporting] = useState(false); const [state, setState] = useState<Partial<Entity>>({}); const facadeURL = safeConvertURL(state.credit_facade_url); const errors: FormErrors<Entity> = { @@ -115,9 +117,25 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { credit_facade_url, }); }; - return ( <div> + {importing && <ImportingAccountModal onCancel={()=> {setImporting(false)}} onConfirm={(ac) => { + state.payto_uri = ac.accountURI + const u = new URL(ac.infoURL) + u.password = "" + if (u.username || u.password) { + state.credit_facade_credentials = { + type: "basic", + password: u.password, + username: u.username, + } + state.repeatPassword = u.password + } + u.password = "" + u.username = "" + state.credit_facade_url = u.href; + setImporting(false) + }} />} <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -171,6 +189,16 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </FormProvider> <div class="buttons is-right mt-5"> + <button + class="button is-info" + data-tooltip={i18n.str`Need to complete marked fields`} + onClick={() => { + setImporting(true) + }} + > + <i18n.Translate>Import from bank</i18n.Translate> + </button> + {onBack && ( <button class="button" onClick={onBack}> <i18n.Translate>Cancel</i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -24,6 +24,7 @@ import { HttpStatusCode, OperationFail, OperationOk, + PaytoString, TalerError, TalerMerchantApi, TalerRevenueHttpClient, @@ -67,51 +68,55 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { const resp = await testRevenueAPI( revenueAPI, request.credit_facade_credentials, + request.payto_uri, ); + if (resp instanceof TalerError) { + setNotif({ + message: i18n.str`Could not add bank account`, + type: "ERROR", + description: i18n.str`The request to check the revenue API failed.`, + details: JSON.stringify(resp.errorDetail, undefined, 2), + }); + return; + } if (resp.type === "fail") { switch (resp.case) { - case TestRevenueErrorType.NO_CONFIG: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, - }); - return; - } - case TestRevenueErrorType.CLIENT_BAD_REQUEST: { + case HttpStatusCode.BadRequest: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", description: i18n.str`Server replied with "bad request".`, }); return; + } - case TestRevenueErrorType.UNAUTHORIZED: { + case HttpStatusCode.Unauthorized: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", description: i18n.str`Unauthorized, try with another credentials.`, }); return; + } - case TestRevenueErrorType.NOT_FOUND: { + case HttpStatusCode.NotFound: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", - description: i18n.str`Check facade URL, server replied with "not found".`, + description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, }); return; } - case TestRevenueErrorType.GENERIC_ERROR: { + case TestRevenueErrorType.ANOTHER_ACCOUNT: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", - description: resp.detail.hint, + description: i18n.str`The account info URL returned information from an account which is not the same in the account form: ${resp.detail.hint}`, }); return; } default: { - assertUnreachable(resp.case); + assertUnreachable(resp); } } } @@ -136,17 +141,18 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { } export enum TestRevenueErrorType { - NO_CONFIG, - CLIENT_BAD_REQUEST, - UNAUTHORIZED, - NOT_FOUND, - GENERIC_ERROR, + ANOTHER_ACCOUNT, } export async function testRevenueAPI( revenueAPI: URL, creds: FacadeCredentials | undefined, -): Promise<OperationOk<void> | OperationFail<TestRevenueErrorType>> { + account: PaytoString, +): Promise<OperationOk<void> | OperationFail<HttpStatusCode.NotFound> +| OperationFail<HttpStatusCode.Unauthorized> +| OperationFail<HttpStatusCode.BadRequest> +| OperationFail<TestRevenueErrorType.ANOTHER_ACCOUNT> +| TalerError> { const api = new TalerRevenueHttpClient( revenueAPI.href, new BrowserFetchHttpLib(), @@ -167,69 +173,33 @@ export async function testRevenueAPI( const config = await api.getConfig(auth); if (config.type === "fail") { - switch (config.case) { - case HttpStatusCode.Unauthorized: { - return { - type: "fail", - case: TestRevenueErrorType.UNAUTHORIZED, - detail: { - code: 1, - }, - }; - } - case HttpStatusCode.NotFound: { - return { - type: "fail", - case: TestRevenueErrorType.NO_CONFIG, - detail: { - code: 1, - }, - }; - } - } + return config; } const history = await api.getHistory(auth); if (history.type === "fail") { - switch (history.case) { - case HttpStatusCode.BadRequest: { - return { - type: "fail", - case: TestRevenueErrorType.CLIENT_BAD_REQUEST, - detail: { - code: 1, - }, - }; - } - case HttpStatusCode.Unauthorized: { - return { - type: "fail", - case: TestRevenueErrorType.UNAUTHORIZED, - detail: { - code: 1, - }, - }; - } - case HttpStatusCode.NotFound: { - return { - type: "fail", - case: TestRevenueErrorType.NOT_FOUND, - detail: { - code: 1, - }, - }; - } - } + return history; } - } catch (err) { - if (err instanceof TalerError) { + if (history.body.credit_account !== account) { return { type: "fail", - case: TestRevenueErrorType.GENERIC_ERROR, - detail: err.errorDetail, + case: TestRevenueErrorType.ANOTHER_ACCOUNT, + detail: { + code: 1, + hint: history.body.credit_account + }, }; } + } catch (err) { + if (err instanceof TalerError) { + return err; + // return { + // type: "fail", + // case: TestRevenueErrorType.GENERIC_ERROR, + // detail: err.errorDetail, + // }; + } } return opFixedSuccess(undefined); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx @@ -48,7 +48,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-bank" /> </span> <i18n.Translate>Bank accounts</i18n.Translate> </p> @@ -240,9 +240,6 @@ function Table({ <th> <i18n.Translate>IBAN</i18n.Translate> </th> - <th> - <i18n.Translate>BIC</i18n.Translate> - </th> <th /> </tr> </thead> @@ -263,12 +260,6 @@ function Table({ > {ac.iban} </td> - <td - onClick={(): void => onSelect(acc)} - style={{ cursor: "pointer" }} - > - {ac.bic ?? ""} - </td> <td class="is-actions-cell right-sticky"> <div class="buttons is-right"> <button diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -88,51 +88,55 @@ export default function UpdateValidator({ const resp = await testRevenueAPI( revenueAPI, request.credit_facade_credentials, + result.body.payto_uri, ); + if (resp instanceof TalerError) { + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: i18n.str`The request to check the revenue API failed.`, + details: JSON.stringify(resp.errorDetail, undefined, 2), + }); + return; + } if (resp.type === "fail") { switch (resp.case) { - case TestRevenueErrorType.NO_CONFIG: { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, - }); - return; - } - case TestRevenueErrorType.CLIENT_BAD_REQUEST: { + case HttpStatusCode.BadRequest: { setNotif({ message: i18n.str`Could not create account`, type: "ERROR", description: i18n.str`Server replied with "bad request".`, }); return; + } - case TestRevenueErrorType.UNAUTHORIZED: { + case HttpStatusCode.Unauthorized: { setNotif({ message: i18n.str`Could not create account`, type: "ERROR", description: i18n.str`Unauthorized, try with another credentials.`, }); return; + } - case TestRevenueErrorType.NOT_FOUND: { + case HttpStatusCode.NotFound: { setNotif({ message: i18n.str`Could not create account`, type: "ERROR", - description: i18n.str`Check facade URL, server replied with "not found".`, + description: i18n.str`The endpoint doesn't seems to be a Taler Revenue API`, }); return; } - case TestRevenueErrorType.GENERIC_ERROR: { + case TestRevenueErrorType.ANOTHER_ACCOUNT: { setNotif({ - message: i18n.str`Could not create account`, + message: i18n.str`Could not add bank account`, type: "ERROR", - description: resp.detail.hint, + description: i18n.str`The account info URL returned information from an account which is not the same in the account form: ${resp.detail.hint}`, }); return; } default: { - assertUnreachable(resp.case) + assertUnreachable(resp); } } } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatePage.tsx @@ -101,7 +101,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { /> <Input<Entity> name="otp_device_description" - label={i18n.str`Descripiton`} + label={i18n.str`Description`} tooltip={i18n.str`Useful to identify the device physically`} /> <InputSelector<Entity> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx @@ -52,7 +52,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-lock" /> </span> <i18n.Translate>OTP Devices</i18n.Translate> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -145,7 +145,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { template_id: state.id!, template_description: state.description!, template_contract, - required_currency: contract_amount !== undefined ? undefined : config.currency, editable_defaults: { amount: !state.amount_editable ? undefined : (state.amount ?? zero), summary: !state.summary_editable ? undefined : (state.summary ?? ""), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/Table.tsx @@ -56,7 +56,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-qrcode" /> </span> <i18n.Translate>Templates</i18n.Translate> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -161,7 +161,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { return onUpdate({ template_description: state.description!, template_contract, - required_currency: contract_amount !== undefined ? undefined : config.currency, editable_defaults: { amount: !state.amount_editable ? undefined : (state.amount ?? zero), summary: !state.summary_editable ? undefined : (state.summary ?? ""), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx @@ -52,7 +52,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-newspaper" /> + <i class="mdi mdi-webhook" /> </span> <i18n.Translate>Webhooks</i18n.Translate> </p> diff --git a/packages/pogen/package.json b/packages/pogen/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/pogen", - "version": "0.10.7", + "version": "0.11.4", "bin": { "pogen": "bin/pogen" }, diff --git a/packages/taler-harness/debian/changelog b/packages/taler-harness/debian/changelog @@ -1,3 +1,27 @@ +taler-harness (0.11.4) unstable; urgency=low + + * Release 0.11.4 + + -- Florian Dold <dold@taler.net> Mon, 10 Jun 2024 19:57:55 +0200 + +taler-harness (0.11.3) unstable; urgency=low + + * Release 0.11.3 + + -- Florian Dold <dold@taler.net> Fri, 07 Jun 2024 19:12:44 +0200 + +taler-harness (0.11.2) unstable; urgency=low + + * Release 0.11.2 + + -- Florian Dold <dold@taler.net> Wed, 05 Jun 2024 20:17:56 +0200 + +taler-harness (0.11.1) unstable; urgency=low + + * Release 0.11.1 + + -- Florian Dold <dold@taler.net> Mon, 27 May 2024 14:46:35 -0600 + taler-harness (0.10.7) unstable; urgency=low * Release 0.10.7 diff --git a/packages/taler-harness/package.json b/packages/taler-harness/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-harness", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.12.0" diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -274,6 +274,7 @@ export class GlobalTestState { procs: ProcessWrapper[]; servers: http.Server[]; inShutdown: boolean = false; + stepSet: Set<string> = new Set(); constructor(params: GlobalTestParams) { this.testDir = params.testDir; this.procs = []; @@ -423,6 +424,9 @@ export class GlobalTestState { // Now we just log, later we may report the steps that were done // to easily see where the test hangs. console.info(`STEP: ${stepName}`); + if (this.stepSet.has(stepName)) { + throw Error(`duplicate step (${stepName})`); + } } } diff --git a/packages/taler-harness/src/harness/helpers.ts b/packages/taler-harness/src/harness/helpers.ts @@ -116,6 +116,8 @@ export interface EnvOptions { mixedAgeRestriction?: boolean; + skipWireFeeCreation?: boolean; + additionalExchangeConfig?(e: ExchangeService): void; additionalMerchantConfig?(m: MerchantService): void; additionalBankConfig?(b: BankService): void; @@ -466,11 +468,12 @@ export async function createSimpleTestkudosEnvironmentV3( bank.corebankApiBaseUrl, ).href; - const exchangeBankAccount = { + const exchangeBankAccount: HarnessExchangeBankAccount = { wireGatewayApiBaseUrl, accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, accountPaytoUri: exchangePaytoUri, + skipWireFeeCreation: opts.skipWireFeeCreation === true, }; await exchange.addBankAccount("1", exchangeBankAccount); diff --git a/packages/taler-harness/src/integrationtests/test-currency-scope.ts b/packages/taler-harness/src/integrationtests/test-currency-scope.ts @@ -17,13 +17,14 @@ /** * Imports. */ -import { Duration, j2s } from "@gnu-taler/taler-util"; +import { Duration, TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { + BankService, ExchangeService, - FakebankService, GlobalTestState, + HarnessExchangeBankAccount, MerchantService, generateRandomPayto, setupDb, @@ -31,6 +32,7 @@ import { import { createWalletDaemonWithClient, withdrawViaBankV2, + withdrawViaBankV3, } from "../harness/helpers.js"; /** @@ -44,7 +46,7 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { nameSuffix: "exchange2", }); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: dbDefault.connStr, @@ -72,17 +74,25 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { database: dbDefault.connStr, }); - const exchangeOneBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - await exchangeOne.addBankAccount("1", exchangeOneBankAccount); - - const exchangeTwoBankAccount = await bank.createExchangeAccount( - "myexchange2", - "x", - ); - await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + let exchangeOneBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + }; + + let exchangeTwoBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange2/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange2", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange2"), + }; bank.setSuggestedExchange( exchangeOne, @@ -93,6 +103,31 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { await bank.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + await bankClient.registerAccountExtended({ + name: exchangeOneBankAccount.accountName, + username: exchangeOneBankAccount.accountName, + password: exchangeOneBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeOneBankAccount.accountPaytoUri, + }); + await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + + await bankClient.registerAccountExtended({ + name: exchangeTwoBankAccount.accountName, + username: exchangeTwoBankAccount.accountName, + password: exchangeTwoBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeTwoBankAccount.accountPaytoUri, + }); + await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + // Set up the first exchange exchangeOne.addOfferedCoins(defaultCoinConfig); @@ -139,16 +174,16 @@ export async function runCurrencyScopeTest(t: GlobalTestState) { // Withdraw digital cash into the wallet. - const w1 = await withdrawViaBankV2(t, { + const w1 = await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeOne, amount: "TESTKUDOS:6", }); - const w2 = await withdrawViaBankV2(t, { + const w2 = await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeTwo, amount: "TESTKUDOS:6", }); diff --git a/packages/taler-harness/src/integrationtests/test-multiexchange.ts b/packages/taler-harness/src/integrationtests/test-multiexchange.ts @@ -17,13 +17,14 @@ /** * Imports. */ -import { Duration, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { Duration, TalerCorebankApiClient, TalerMerchantApi } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { + BankService, ExchangeService, - FakebankService, GlobalTestState, + HarnessExchangeBankAccount, MerchantService, generateRandomPayto, setupDb, @@ -32,6 +33,7 @@ import { createWalletDaemonWithClient, makeTestPaymentV2, withdrawViaBankV2, + withdrawViaBankV3, } from "../harness/helpers.js"; /** @@ -45,7 +47,7 @@ export async function runMultiExchangeTest(t: GlobalTestState) { nameSuffix: "exchange2", }); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: dbDefault.connStr, @@ -73,17 +75,25 @@ export async function runMultiExchangeTest(t: GlobalTestState) { database: dbDefault.connStr, }); - const exchangeOneBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + let exchangeOneBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + }; - const exchangeTwoBankAccount = await bank.createExchangeAccount( - "myexchange2", - "x", - ); - await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + let exchangeTwoBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange2/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange2", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange2"), + }; bank.setSuggestedExchange( exchangeOne, @@ -94,6 +104,31 @@ export async function runMultiExchangeTest(t: GlobalTestState) { await bank.pingUntilAvailable(); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + await bankClient.registerAccountExtended({ + name: exchangeOneBankAccount.accountName, + username: exchangeOneBankAccount.accountName, + password: exchangeOneBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeOneBankAccount.accountPaytoUri, + }); + await exchangeOne.addBankAccount("1", exchangeOneBankAccount); + + await bankClient.registerAccountExtended({ + name: exchangeTwoBankAccount.accountName, + username: exchangeTwoBankAccount.accountName, + password: exchangeTwoBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeTwoBankAccount.accountPaytoUri, + }); + await exchangeTwo.addBankAccount("1", exchangeTwoBankAccount); + // Set up the first exchange exchangeOne.addOfferedCoins(defaultCoinConfig); @@ -141,16 +176,16 @@ export async function runMultiExchangeTest(t: GlobalTestState) { // Withdraw digital cash into the wallet. - await withdrawViaBankV2(t, { + await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeOne, amount: "TESTKUDOS:6", }); - await withdrawViaBankV2(t, { + await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeTwo, amount: "TESTKUDOS:6", }); diff --git a/packages/taler-harness/src/integrationtests/test-payment-template.ts b/packages/taler-harness/src/integrationtests/test-payment-template.ts @@ -93,7 +93,9 @@ export async function runPaymentTemplateTest(t: GlobalTestState) { WalletApiOperation.PreparePayForTemplate, { talerPayTemplateUri, - templateParams: {}, + templateParams: { + amount: "TESTKUDOS:1", + }, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-refund-auto.ts b/packages/taler-harness/src/integrationtests/test-refund-auto.ts @@ -17,7 +17,12 @@ /** * Imports. */ -import { Duration, MerchantApiClient } from "@gnu-taler/taler-util"; +import { + Duration, + MerchantApiClient, + TransactionMajorState, + TransactionMinorState, +} from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { GlobalTestState } from "../harness/harness.js"; import { @@ -47,67 +52,134 @@ export async function runRefundAutoTest(t: GlobalTestState) { await wres.withdrawalFinishedCond; - // Set up order. - const orderResp = await merchantClient.createOrder({ - order: { - summary: "Buy me!", - amount: "TESTKUDOS:5", - fulfillment_url: "taler://fulfillment-success/thx", - auto_refund: { - d_us: 3000 * 1000, + // Test case where the auto-refund happens + { + // Set up order. + const orderResp = await merchantClient.createOrder({ + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + auto_refund: { + d_us: 3000 * 1000, + }, }, - }, - refund_delay: Duration.toTalerProtocolDuration( - Duration.fromSpec({ minutes: 5 }), - ), - }); + refund_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 5 }), + ), + }); - let orderStatus = await merchantClient.queryPrivateOrderStatus({ - orderId: orderResp.order_id, - }); + let orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); - t.assertTrue(orderStatus.order_status === "unpaid"); + t.assertTrue(orderStatus.order_status === "unpaid"); - // Make wallet pay for the order + // Make wallet pay for the order - const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, { - talerPayUri: orderStatus.taler_pay_uri, - }); + const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, { + talerPayUri: orderStatus.taler_pay_uri, + }); - await walletClient.call(WalletApiOperation.ConfirmPay, { - transactionId: r1.transactionId, - }); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: r1.transactionId, + }); - // Check if payment was successful. + // Check if payment was successful. - orderStatus = await merchantClient.queryPrivateOrderStatus({ - orderId: orderResp.order_id, - }); + orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); - t.assertTrue(orderStatus.order_status === "paid"); + t.assertTrue(orderStatus.order_status === "paid"); - const ref = await merchantClient.giveRefund({ - amount: "TESTKUDOS:5", - instance: "default", - justification: "foo", - orderId: orderResp.order_id, - }); + const ref = await merchantClient.giveRefund({ + amount: "TESTKUDOS:5", + instance: "default", + justification: "foo", + orderId: orderResp.order_id, + }); + + console.log(ref); + + // The wallet should now automatically pick up the refund. + await walletClient.call( + WalletApiOperation.TestingWaitTransactionsFinal, + {}, + ); + + const transactions = await walletClient.call( + WalletApiOperation.GetTransactions, + { + sort: "stable-ascending", + }, + ); + console.log(JSON.stringify(transactions, undefined, 2)); + + const transactionTypes = transactions.transactions.map((x) => x.type); + t.assertDeepEqual(transactionTypes, ["withdrawal", "payment", "refund"]); + } + + // Now test the case where the auto-refund just expires + + { + // Set up order. + const orderResp = await merchantClient.createOrder({ + order: { + summary: "Buy me!", + amount: "TESTKUDOS:5", + fulfillment_url: "taler://fulfillment-success/thx", + auto_refund: { + d_us: 3000 * 1000, + }, + }, + refund_delay: Duration.toTalerProtocolDuration( + Duration.fromSpec({ minutes: 5 }), + ), + }); + + let orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); + + t.assertTrue(orderStatus.order_status === "unpaid"); - console.log(ref); + // Make wallet pay for the order - // The wallet should now automatically pick up the refund. - await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + const r1 = await walletClient.call(WalletApiOperation.PreparePayForUri, { + talerPayUri: orderStatus.taler_pay_uri, + }); - const transactions = await walletClient.call( - WalletApiOperation.GetTransactions, - {}, - ); - console.log(JSON.stringify(transactions, undefined, 2)); + await walletClient.call(WalletApiOperation.ConfirmPay, { + transactionId: r1.transactionId, + }); - const transactionTypes = transactions.transactions.map((x) => x.type); - t.assertDeepEqual(transactionTypes, ["withdrawal", "payment", "refund"]); + // Check if payment was successful. - await t.shutdown(); + orderStatus = await merchantClient.queryPrivateOrderStatus({ + orderId: orderResp.order_id, + }); + + t.assertTrue(orderStatus.order_status === "paid"); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: r1.transactionId, + txState: { + major: TransactionMajorState.Pending, + minor: TransactionMinorState.AutoRefund, + }, + }); + // Only time-travel the wallet + await walletClient.call(WalletApiOperation.TestingSetTimetravel, { + offsetMs: 5000, + }); + await walletClient.call(WalletApiOperation.TestingWaitTransactionState, { + transactionId: r1.transactionId, + txState: { + major: TransactionMajorState.Done, + }, + }); + } } runRefundAutoTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-harness/src/integrationtests/test-timetravel-autorefresh.ts @@ -24,11 +24,12 @@ import { NotificationType, PreparePayResultType, TalerCorebankApiClient, + j2s, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; import { - BankService, + BankService, ExchangeService, GlobalTestState, MerchantService, @@ -78,7 +79,10 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { await exchange.addBankAccount("1", { accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.baseUrl, + ).href, accountPaytoUri: exchangePaytoUri, }); @@ -129,29 +133,42 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { const { walletClient } = await createWalletDaemonWithClient(t, { name: "w1", + persistent: true, }); const merchantClient = new MerchantApiClient(merchant.makeInstanceBaseUrl()); - // Withdraw digital cash into the wallet. + t.logStep("exchangeUpdated1Cond"); + // Withdraw digital cash into the wallet. + t.logStep("Withdraw digital cash into the wallet."); const wres = await withdrawViaBankV3(t, { walletClient, bankClient, exchange, amount: "TESTKUDOS:15", }); + t.logStep("wait"); await wres.withdrawalFinishedCond; - const exchangeUpdated1Cond = walletClient.waitForNotificationCond( (x) => - x.type === NotificationType.ExchangeStateTransition && - x.exchangeBaseUrl === exchange.baseUrl, + { + t.logStep(`EXCHANGE UPDATE, ${j2s(x)}`) + return x.type === NotificationType.ExchangeStateTransition && + x.exchangeBaseUrl === exchange.baseUrl + } ); + t.logStep("waiting tx"); + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:15"); + } + // Travel into the future, the deposit expiration is two years // into the future. - console.log("applying first time travel"); + t.logStep("applying first time travel"); await applyTimeTravelV2( Duration.toMilliseconds(Duration.fromSpec({ days: 400 })), { @@ -162,9 +179,16 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { ); // The time travel should cause exchanges to update. + t.logStep("The time travel should cause exchanges to update"); await exchangeUpdated1Cond; + t.logStep("exchange updated, waiting for tx"); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:15"); + } + t.logStep("withdrawing second time"); const wres2 = await withdrawViaBankV3(t, { walletClient, bankClient, @@ -173,8 +197,14 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { }); await wres2.withdrawalFinishedCond; + t.logStep("witdrawn, waiting tx"); await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:35"); + } + const exchangeUpdated2Cond = walletClient.waitForNotificationCond( (x) => x.type === NotificationType.ExchangeStateTransition && @@ -183,7 +213,7 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { // Travel into the future, the deposit expiration is two years // into the future. - console.log("applying second time travel"); + t.logStep("applying second time travel"); await applyTimeTravelV2( Duration.toMilliseconds(Duration.fromSpec({ years: 2, months: 6 })), { @@ -194,8 +224,13 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { ); // The time travel should cause exchanges to update. + t.logStep("The time travel should cause exchanges to update."); await exchangeUpdated2Cond; await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + { + const balance = await walletClient.call(WalletApiOperation.GetBalances, {}); + t.assertAmountEquals(balance.balances[0].available, "TESTKUDOS:35"); + } // At this point, the original coins should've been refreshed. // It would be too late to refresh them now, as we're past diff --git a/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts b/packages/taler-harness/src/integrationtests/test-wallet-denom-expire.ts @@ -17,21 +17,16 @@ /** * Imports. */ -import { Duration, Logger, NotificationType, TalerCorebankApiClient, j2s } from "@gnu-taler/taler-util"; +import { Duration, Logger, NotificationType, j2s } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { makeNoFeeCoinConfig } from "../harness/denomStructures.js"; import { - BankService, - ExchangeService, - FakebankService, GlobalTestState, - MerchantService, - generateRandomPayto, setupDb, } from "../harness/harness.js"; import { applyTimeTravelV2, - createWalletDaemonWithClient, + createSimpleTestkudosEnvironmentV3, withdrawViaBankV3, } from "../harness/helpers.js"; @@ -45,89 +40,14 @@ export async function runWalletDenomExpireTest(t: GlobalTestState) { const db = await setupDb(t); - const bank = await BankService.create(t, { - allowRegistrations: true, - currency: "TESTKUDOS", - database: db.connStr, - httpPort: 8082, - }); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - let receiverName = "Exchange"; - let exchangeBankUsername = "exchange"; - let exchangeBankPassword = "mypw"; - let exchangePaytoUri = generateRandomPayto(exchangeBankUsername); - - await exchange.addBankAccount("1", { - accountName: exchangeBankUsername, - accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, - accountPaytoUri: exchangePaytoUri, - }); - - bank.setSuggestedExchange(exchange, exchangePaytoUri); - - await bank.start(); - - await bank.pingUntilAvailable(); - - const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { - auth: { - username: "admin", - password: "adminpw", - }, - }); - - await bankClient.registerAccountExtended({ - name: receiverName, - password: exchangeBankPassword, - username: exchangeBankUsername, - is_taler_exchange: true, - payto_uri: exchangePaytoUri, - }); - - exchange.addCoinConfigList(makeNoFeeCoinConfig("TESTKUDOS")); - - await exchange.start(); - await exchange.pingUntilAvailable(); - - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); - - console.log("merchant started, configuring instances"); - - await merchant.addInstanceWithWireAccount({ - id: "default", - name: "Default Instance", - paytoUris: [generateRandomPayto("merchant-default")], - }); + const coinConfig = makeNoFeeCoinConfig("TESTKUDOS"); - await merchant.addInstanceWithWireAccount({ - id: "minst1", - name: "minst1", - paytoUris: [generateRandomPayto("minst1")], - }); - - console.log("setup done!"); - - const { walletClient } = await createWalletDaemonWithClient(t, { - name: "default", - }); + const { + walletClient, + bankClient, + exchange, + merchant, + } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, {}); // Withdraw digital cash into the wallet. diff --git a/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts b/packages/taler-harness/src/integrationtests/test-wallet-exchange-update.ts @@ -21,6 +21,7 @@ import { AmountString, ExchangeUpdateStatus, NotificationType, + TalerCorebankApiClient, j2s, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -30,11 +31,14 @@ import { ExchangeService, FakebankService, GlobalTestState, + HarnessExchangeBankAccount, + generateRandomPayto, setupDb, } from "../harness/harness.js"; import { createWalletDaemonWithClient, withdrawViaBankV2, + withdrawViaBankV3, } from "../harness/helpers.js"; /** @@ -51,7 +55,7 @@ export async function runWalletExchangeUpdateTest( nameSuffix: "two", }); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: db.connStr, @@ -75,10 +79,27 @@ export async function runWalletExchangeUpdateTest( database: db2.connStr, }); - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: { + username: "admin", + password: "adminpw", + }, + }); + + // const exchangeBankAccount = await bank.createExchangeAccount( + // "myexchange", + // "x", + // ); + + let exchangeBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + }; await exchangeOne.addBankAccount("1", exchangeBankAccount); await exchangeTwo.addBankAccount("1", exchangeBankAccount); @@ -88,6 +109,14 @@ export async function runWalletExchangeUpdateTest( await bank.start(); + bankClient.registerAccountExtended({ + name: exchangeBankAccount.accountName, + username: exchangeBankAccount.accountName, + password: exchangeBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeBankAccount.accountPaytoUri, + }); + exchangeOne.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); exchangeTwo.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); @@ -108,9 +137,9 @@ export async function runWalletExchangeUpdateTest( t.assertDeepEqual(exchangesListResult.exchanges.length, 0); - const wres = await withdrawViaBankV2(t, { + const wres = await withdrawViaBankV3(t, { walletClient, - bank, + bankClient, exchange: exchangeOne, amount: "TESTKUDOS:10", }); diff --git a/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts b/packages/taler-harness/src/integrationtests/test-wallet-insufficient-balance.ts @@ -22,71 +22,32 @@ import { Duration, PaymentInsufficientBalanceDetails, TalerErrorCode, - WalletNotification, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { - ExchangeService, - FakebankService, GlobalTestState, - MerchantService, - WalletClient, - WalletService, generateRandomPayto, setupDb, } from "../harness/harness.js"; -import { withdrawViaBankV2 } from "../harness/helpers.js"; +import { createSimpleTestkudosEnvironmentV3, withdrawViaBankV3 } from "../harness/helpers.js"; export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { // Set up test environment const db = await setupDb(t); - const bank = await FakebankService.create(t, { - allowRegistrations: true, - currency: "TESTKUDOS", - database: db.connStr, - httpPort: 8082, - }); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "TESTKUDOS", - httpPort: 8081, - database: db.connStr, - }); - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - currency: "TESTKUDOS", - httpPort: 8083, - database: db.connStr, - }); - - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchangeBankAccount.skipWireFeeCreation = true; - exchange.addBankAccount("1", exchangeBankAccount); - - bank.setSuggestedExchange(exchange, exchangeBankAccount.accountPaytoUri); - - await bank.start(); - - await bank.pingUntilAvailable(); - const coinConfig: CoinConfig[] = defaultCoinConfig.map((x) => x("TESTKUDOS")); - exchange.addCoinConfigList(coinConfig); - - await exchange.start(); - await exchange.pingUntilAvailable(); - merchant.addExchange(exchange); - - await merchant.start(); - await merchant.pingUntilAvailable(); + let { + bankClient, + exchange, + merchant, + walletService, + walletClient, + } = await createSimpleTestkudosEnvironmentV3(t, coinConfig, { + skipWireFeeCreation: true, + }); await merchant.addInstanceWithWireAccount({ id: "default", @@ -106,24 +67,6 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { ), }); - const walletService = new WalletService(t, { - name: "wallet", - useInMemoryDb: true, - }); - await walletService.start(); - await walletService.pingUntilAvailable(); - - const allNotifications: WalletNotification[] = []; - - const walletClient = new WalletClient({ - name: "wallet", - unixPath: walletService.socketPath, - onNotification(n) { - console.log("got notification", n); - allNotifications.push(n); - }, - }); - await walletClient.connect(); await walletClient.client.call(WalletApiOperation.InitWallet, { config: { testing: { @@ -132,9 +75,9 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { }, }); - const wres = await withdrawViaBankV2(t, { + const wres = await withdrawViaBankV3(t, { amount: "TESTKUDOS:10", - bank, + bankClient, exchange, walletClient, }); @@ -146,10 +89,12 @@ export async function runWalletInsufficientBalanceTest(t: GlobalTestState) { depositPaytoUri: "payto://x-taler-bank/localhost/foobar", }); }); + t.assertDeepEqual( exc.errorDetail.code, TalerErrorCode.WALLET_DEPOSIT_GROUP_INSUFFICIENT_BALANCE, ); + const insufficientBalanceDetails: PaymentInsufficientBalanceDetails = exc.errorDetail.insufficientBalanceDetails; diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-bank-integrated.ts @@ -46,7 +46,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { "TESTKUDOS:10", ); - // Hand it to the wallet + t.logStep("Hand it to the wallet") const r1 = await walletClient.client.call( WalletApiOperation.GetWithdrawalDetailsForUri, @@ -55,7 +55,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); - // Withdraw + t.logStep("Withdraw") const r2 = await walletClient.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, @@ -65,6 +65,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); + t.logStep("wait confirmed") const withdrawalBankConfirmedCond = walletClient.waitForNotificationCond( (x) => { return ( @@ -76,6 +77,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); + t.logStep("wait finished") const withdrawalFinishedCond = walletClient.waitForNotificationCond((x) => { return ( x.type === NotificationType.TransactionStateTransition && @@ -84,6 +86,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { ); }); + t.logStep("wait withdraw coins") const withdrawalReserveReadyCond = walletClient.waitForNotificationCond( (x) => { return ( @@ -95,7 +98,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); - // Do it twice to check idempotency + t.logStep("Do it twice to check idempotency") const r3 = await walletClient.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, { @@ -104,9 +107,10 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { }, ); + t.logStep("stop wirewatch") await exchange.stopWirewatch(); - // Check status before withdrawal is confirmed by bank. + t.logStep("Check status before withdrawal is confirmed by bank.") { const txn = await walletClient.client.call( WalletApiOperation.GetTransactions, @@ -122,7 +126,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false); } - // Confirm it + t.logStep("Confirm it") await bankClient.confirmWithdrawalOperation(user.username, { withdrawalOperationId: wop.withdrawal_id, @@ -132,6 +136,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { // Check status after withdrawal is confirmed by bank, // but before funds are wired to the exchange. + t.logStep("Check status after withdrawal") { const txn = await walletClient.client.call( WalletApiOperation.GetTransactions, @@ -147,11 +152,13 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { t.assertTrue(tx0.withdrawalDetails.reserveIsReady === false); } + t.logStep("start wirewatch") await exchange.startWirewatch(); + t.logStep("wait reserve") await withdrawalReserveReadyCond; - // Check status after funds were wired. + t.logStep("Check status after funds were wired.") { const txn = await walletClient.client.call( WalletApiOperation.GetTransactions, @@ -169,7 +176,7 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { await withdrawalFinishedCond; - // Check balance + t.logStep("Check balance") const balResp = await walletClient.client.call( WalletApiOperation.GetBalances, diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-conversion.ts @@ -33,9 +33,11 @@ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import * as http from "node:http"; import { defaultCoinConfig } from "../harness/denomStructures.js"; import { + BankService, ExchangeService, FakebankService, GlobalTestState, + HarnessExchangeBankAccount, MerchantService, generateRandomPayto, setupDb, @@ -135,7 +137,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { const db = await setupDb(t); - const bank = await FakebankService.create(t, { + const bank = await BankService.create(t, { allowRegistrations: true, currency: "TESTKUDOS", database: db.connStr, @@ -156,17 +158,40 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { database: db.connStr, }); - const exchangeBankAccount = await bank.createExchangeAccount( - "myexchange", - "x", - ); - exchangeBankAccount.conversionUrl = "http://localhost:8071/"; + let exchangeBankAccount: HarnessExchangeBankAccount = { + wireGatewayApiBaseUrl: new URL( + "accounts/myexchange/taler-wire-gateway/", + bank.corebankApiBaseUrl, + ).href, + accountName: "myexchange", + accountPassword: "x", + accountPaytoUri: generateRandomPayto("myexchange"), + conversionUrl: "http://localhost:8071/", + }; + await exchange.addBankAccount("1", exchangeBankAccount); await bank.start(); await bank.pingUntilAvailable(); + const bankClientAuth = { + username: "admin", + password: "adminpw", + }; + + const bankClient = new TalerCorebankApiClient(bank.corebankApiBaseUrl, { + auth: bankClientAuth, + }); + + await bankClient.registerAccountExtended({ + name: exchangeBankAccount.accountName, + username: exchangeBankAccount.accountName, + password: exchangeBankAccount.accountPassword, + is_taler_exchange: true, + payto_uri: exchangeBankAccount.accountPaytoUri, + }); + exchange.addOfferedCoins(defaultCoinConfig); await exchange.start(); @@ -194,7 +219,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { ), }); - const { walletClient, walletService } = await createWalletDaemonWithClient( + const { walletClient } = await createWalletDaemonWithClient( t, { name: "wallet" }, ); @@ -203,11 +228,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { // Create a withdrawal operation - const bankAccessApiClient = new TalerCorebankApiClient( - bank.corebankApiBaseUrl, - ); - - const user = await bankAccessApiClient.createRandomBankUser(); + const user = await bankClient.createRandomBankUser(); await walletClient.call(WalletApiOperation.AddExchange, { exchangeBaseUrl: exchange.baseUrl, @@ -277,10 +298,7 @@ export async function runWithdrawalConversionTest(t: GlobalTestState) { const wireGatewayApiClient = new WireGatewayApiClient( exchangeBankAccount.wireGatewayApiBaseUrl, { - auth: { - username: exchangeBankAccount.accountName, - password: exchangeBankAccount.accountPassword, - }, + auth: bankClientAuth, }, ); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fees.ts @@ -90,7 +90,10 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { await exchange.addBankAccount("1", { accountName: exchangeBankUsername, accountPassword: exchangeBankPassword, - wireGatewayApiBaseUrl: new URL("accounts/exchange/taler-wire-gateway/", bank.baseUrl).href, + wireGatewayApiBaseUrl: new URL( + "accounts/exchange/taler-wire-gateway/", + bank.baseUrl, + ).href, accountPaytoUri: exchangePaytoUri, }); @@ -133,12 +136,9 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { const user = await bankClient.createRandomBankUser(); bankClient.setAuth(user); - const wop = await bankClient.createWithdrawalOperation( - user.username, - amount, - ); + const wop = await bankClient.createWithdrawalOperation(user.username, amount); - // Hand it to the wallet + t.logStep("Hand it to the wallet") const details = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForUri, @@ -149,10 +149,13 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { console.log(j2s(details)); + const myAmount = details.amount; + t.assertTrue(!!myAmount); + const amountDetails = await wallet.client.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { - amount: details.amount, + amount: myAmount, exchangeBaseUrl: details.possibleExchanges[0].exchangeBaseUrl, }, ); @@ -162,23 +165,25 @@ export async function runWithdrawalFeesTest(t: GlobalTestState) { t.assertAmountEquals(amountDetails.amountEffective, "TESTKUDOS:5"); t.assertAmountEquals(amountDetails.amountRaw, "TESTKUDOS:7.5"); + t.logStep("Complete all pending operations") + await wallet.runPending(); - // Withdraw (AKA select) + t.logStep("Withdraw (AKA select)") await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: exchange.baseUrl, talerWithdrawUri: wop.taler_withdraw_uri, }); - // Confirm it + t.logStep("Confirm it") await bankClient.confirmWithdrawalOperation(user.username, { withdrawalOperationId: wop.withdrawal_id, }); await wallet.runUntilDone(); - // Check balance + t.logStep("Check balance") const balResp = await wallet.client.call(WalletApiOperation.GetBalances, {}); console.log(j2s(balResp)); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-flex.ts @@ -0,0 +1,70 @@ +/* + This file is part of GNU Taler + (C) 2020 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import { j2s } from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { GlobalTestState } from "../harness/harness.js"; +import { createSimpleTestkudosEnvironmentV3 } from "../harness/helpers.js"; + +/** + * Run test for bank-integrated withdrawal with flexible amount, + * i.e. the amount is chosen by the wallet. + */ +export async function runWithdrawalFlexTest(t: GlobalTestState) { + // Set up test environment + + const { walletClient, bankClient, exchange } = + await createSimpleTestkudosEnvironmentV3(t); + + // Create a withdrawal operation + const user = await bankClient.createRandomBankUser(); + bankClient.setAuth(user); + const wop = await bankClient.createWithdrawalOperation( + user.username, + undefined, + ); + + const r1 = await walletClient.call( + WalletApiOperation.GetWithdrawalDetailsForUri, + { + talerWithdrawUri: wop.taler_withdraw_uri, + }, + ); + + console.log(j2s(r1)); + + // Withdraw + + const r2 = await walletClient.call( + WalletApiOperation.AcceptBankIntegratedWithdrawal, + { + exchangeBaseUrl: exchange.baseUrl, + talerWithdrawUri: wop.taler_withdraw_uri, + amount: "TESTKUDOS:10", + }, + ); + + await bankClient.confirmWithdrawalOperation(user.username, { + withdrawalOperationId: wop.withdrawal_id, + }); + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); +} + +runWithdrawalFlexTest.suites = ["wallet"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -113,14 +113,15 @@ import { runWalletRefreshTest } from "./test-wallet-refresh.js"; import { runWalletWirefeesTest } from "./test-wallet-wirefees.js"; import { runWallettestingTest } from "./test-wallettesting.js"; import { runWithdrawalAbortBankTest } from "./test-withdrawal-abort-bank.js"; +import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; import { runWithdrawalBankIntegratedTest } from "./test-withdrawal-bank-integrated.js"; import { runWithdrawalConversionTest } from "./test-withdrawal-conversion.js"; import { runWithdrawalFakebankTest } from "./test-withdrawal-fakebank.js"; import { runWithdrawalFeesTest } from "./test-withdrawal-fees.js"; +import { runWithdrawalFlexTest } from "./test-withdrawal-flex.js"; import { runWithdrawalHandoverTest } from "./test-withdrawal-handover.js"; import { runWithdrawalHugeTest } from "./test-withdrawal-huge.js"; import { runWithdrawalManualTest } from "./test-withdrawal-manual.js"; -import { runWithdrawalAmountTest } from "./test-withdrawal-amount.js"; /** * Test runner. @@ -232,6 +233,7 @@ const allTests: TestMainFunction[] = [ runPeerPushLargeTest, runWithdrawalHandoverTest, runWithdrawalAmountTest, + runWithdrawalFlexTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/package.json b/packages/taler-util/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-util", - "version": "0.10.7", + "version": "0.11.4", "description": "Generic helper functionality for GNU Taler", "type": "module", "types": "./lib/index.node.d.ts", diff --git a/packages/taler-util/src/CancellationToken.ts b/packages/taler-util/src/CancellationToken.ts @@ -172,7 +172,7 @@ class CancellationToken { } = CancellationToken.create(); let timer: NodeJS.Timeout | null; - timer = setTimeout(() => originalCancel(CancellationToken.timeout), ms); + timer = setTimeout(() => originalCancel(`CancellationToken.timeout ${ms}`), ms); const disposeTimer = () => { if (timer == null) return; clearTimeout(timer); diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts @@ -385,7 +385,7 @@ export class TalerCorebankApiClient { async createWithdrawalOperation( user: string, - amount: string, + amount: string | undefined, ): Promise<WithdrawalOperationInfo> { const url = new URL(`accounts/${user}/withdrawals`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { diff --git a/packages/taler-util/src/errors.ts b/packages/taler-util/src/errors.ts @@ -166,6 +166,11 @@ export interface DetailsMap { [TalerErrorCode.WALLET_DB_UNAVAILABLE]: { innerError: TalerErrorDetail | undefined; }; + [TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED]: { + exchangeBaseUrl: string; + tosStatus: string; + currentEtag: string | undefined; + }; } type ErrBody<Y> = Y extends keyof DetailsMap ? DetailsMap[Y] : empty; diff --git a/packages/taler-util/src/http-client/bank-integration.ts b/packages/taler-util/src/http-client/bank-integration.ts @@ -50,7 +50,9 @@ export type TalerBankIntegrationErrorsByMethod< * The API is used by the wallets. */ export class TalerBankIntegrationHttpClient { - public readonly PROTOCOL_VERSION = "2:0:2"; + public static readonly PROTOCOL_VERSION = "2:0:1"; + public readonly PROTOCOL_VERSION = + TalerBankIntegrationHttpClient.PROTOCOL_VERSION; httpLib: HttpRequestLibrary; @@ -147,6 +149,10 @@ export class TalerBankIntegrationHttpClient { return opKnownTalerFailure(details.code, details); case TalerErrorCode.BANK_ACCOUNT_IS_NOT_EXCHANGE: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return opKnownTalerFailure(details.code, details); default: return opUnknownFailure(resp, details); } diff --git a/packages/taler-util/src/http-client/bank-revenue.ts b/packages/taler-util/src/http-client/bank-revenue.ts @@ -25,6 +25,7 @@ import { LibtoolVersion } from "../libtool-version.js"; import { FailCasesByMethod, ResultByMethod, + opFixedSuccess, opKnownHttpFailure, opSuccessFromHttp, opUnknownFailure, @@ -117,6 +118,9 @@ export class TalerRevenueHttpClient { switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForRevenueIncomingHistory()); + // FIXME: missing in docs + case HttpStatusCode.NoContent: + return opFixedSuccess({incoming_transactions: [], credit_account: "" }); case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Unauthorized: diff --git a/packages/taler-util/src/http-client/types.ts b/packages/taler-util/src/http-client/types.ts @@ -360,6 +360,7 @@ export const codecForCoreBankConfig = (): Codec<TalerCorebankApi.Config> => ), ) .property("wire_type", codecOptionalDefault(codecForString(), "iban")) + .property("wire_transfer_fees", codecOptional(codecForAmountString())) .build("TalerCorebankApi.Config"); //FIXME: implement this codec @@ -902,7 +903,6 @@ export const codecForTemplateDetails = .property("template_description", codecForString()) .property("otp_id", codecOptional(codecForString())) .property("template_contract", codecForTemplateContractDetails()) - .property("required_currency", codecOptional(codecForString())) .property( "editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()), @@ -931,7 +931,6 @@ export const codecForWalletTemplateDetails = (): Codec<TalerMerchantApi.WalletTemplateDetails> => buildCodecForObject<TalerMerchantApi.WalletTemplateDetails>() .property("template_contract", codecForTemplateContractDetails()) - .property("required_currency", codecOptional(codecForString())) .property( "editable_defaults", codecOptional(codecForTemplateContractDetailsDefaults()), @@ -1311,9 +1310,12 @@ export const codecForBankWithdrawalOperationStatus = codecForConstString("confirmed"), ), ) - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) + .property("currency", codecOptional(codecForCurrencyName())) + .property("suggested_amount", codecOptional(codecForAmountString())) + .property("card_fees", codecOptional(codecForAmountString())) .property("sender_wire", codecOptional(codecForPaytoString())) - .property("suggested_exchange", codecOptional(codecForString())) + .property("suggested_exchange", codecOptional(codecForURL())) .property("confirm_transfer_url", codecOptional(codecForURL())) .property("wire_types", codecForList(codecForString())) .property("selected_reserve_pub", codecOptional(codecForString())) @@ -2028,20 +2030,53 @@ export namespace TalerBankIntegrationApi { // confirmed: the transfer has been confirmed and registered by the bank status: WithdrawalOperationStatus; - // Amount that will be withdrawn with this operation - // (raw amount without fee considerations). - amount: AmountString; + // Currency used for the withdrawal. + // MUST be present when amount is absent. + // @since v2, may become mandatory in the future. + currency?: string; - // Bank account of the customer that is withdrawing, as a - // payto URI. + // Amount that will be withdrawn with this operation + // (raw amount without fee considerations). Only + // given once the amount is fixed and cannot be changed. + // Optional since **vC2EC**. + amount?: AmountString | undefined; + + // Suggestion for the amount to be withdrawn with this + // operation. Given if a suggestion was made but the + // user may still change the amount. + // Optional since **vC2EC**. + suggested_amount?: AmountString | undefined; + + // Maximum amount that the wallet can choose to withdraw. + // Only applicable when the amount is not fixed. + // @since **vC2EC**. + max_amount?: AmountString | undefined; + + // The non-Taler card fees the customer will have + // to pay to the bank / payment service provider + // they are using to make the withdrawal. + // @since **vC2EC** + card_fees?: AmountString | undefined; + + // Bank account of the customer that is debiting, as an + // RFC 8905 payto URI. sender_wire?: PaytoString; - // Suggestion for an exchange given by the bank. + // Base URL of the suggested exchange. The bank may have + // neither a suggestion nor a requirement for the exchange. + // This value is typically set in the bank's configuration. suggested_exchange?: string; + // Base URL of an exchange that must be used. Optional, + // not given *unless* a particular exchange is mandatory. + // This value is typically set in the bank's configuration. + // @since **vC2EC** + required_exchange?: string; + // URL that the user needs to navigate to in order to // complete some final confirmation (e.g. 2FA). - // It may contain withdrawal operation id + // Only applicable when status is selected or pending. + // It may contain the withdrawal operation id. confirm_transfer_url?: string; // Wire transfer types supported by the bank. @@ -2051,17 +2086,24 @@ export namespace TalerBankIntegrationApi { // only non-null if status is selected or confirmed. selected_reserve_pub?: string; - // Exchange account selected by the wallet + // Exchange account selected by the wallet; // only non-null if status is selected or confirmed. + // @since **v1** selected_exchange_account?: string; } export interface BankWithdrawalOperationPostRequest { - // Reserve public key. + // Reserve public key that should become the wire transfer + // subject to fund the withdrawal. reserve_pub: string; // Payto address of the exchange selected for the withdrawal. selected_exchange: PaytoString; + + // Selected amount to be transferred. Optional if the + // backend already knows the amount. + // @since **vC2EC** + amount?: AmountString | undefined; } export interface BankWithdrawalOperationPostResponse { @@ -2075,7 +2117,7 @@ export namespace TalerBankIntegrationApi { // URL that the user needs to navigate to in order to // complete some final confirmation (e.g. 2FA). // - // Only applicable when status is selected. + // Only applicable when status is selected or pending. // It may contain withdrawal operation id confirm_transfer_url?: string; } @@ -2150,12 +2192,31 @@ export namespace TalerCorebankApi { // Default to 'iban' is missing // @since v4, may become mandatory in the future. wire_type: string; + + // Wire transfer execution fees. + // @since v4, will become mandatory in the next version. + wire_transfer_fees?: AmountString; } export interface BankAccountCreateWithdrawalRequest { - // Amount to withdraw. - amount: AmountString; + // Amount to withdraw. If given, the wallet + // cannot change the amount. + // Optional since **vC2EC**. + amount?: AmountString; + + // Suggested amount to withdraw. The wallet can + // still change the suggestion. + // @since **vC2EC** + suggested_amount?: AmountString; + + // The non-Taler card fees the customer will have + // to pay to the account owner, bank and/or + // payment service provider + // they are using to make this withdrawal. + // @since **vC2EC** + card_fees?: AmountString; } + export interface BankAccountCreateWithdrawalResponse { // ID of the withdrawal, can be used to view/modify the withdrawal operation. withdrawal_id: string; @@ -2498,10 +2559,6 @@ export namespace TalerCorebankApi { export interface CashoutInfo { cashout_id: number; - /** - * @deprecated since 4, use new 2fa - */ - status?: "pending" | "aborted" | "confirmed"; } export interface GlobalCashouts { // Every string represents a cash-out operation ID. @@ -4693,17 +4750,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateContractDetails { // Human-readable summary for the template. @@ -4755,17 +4801,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateSummaryResponse { @@ -4791,17 +4826,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface TemplateDetails { @@ -4820,17 +4844,6 @@ export namespace TalerMerchantApi { // user-editable defaults for this template. // Since protocol **v13**. editable_defaults?: TemplateContractDetailsDefaults; - - // Required currency for payments. Useful if no - // amount is specified in the template_contract - // but the user should be required to pay in a - // particular currency anyway. Merchant backends - // may reject requests if the template_contract - // or editable_defaults do - // specify an amount in a different currency. - // This parameter is optional. - // Since protocol **v13**. - required_currency?: string; } export interface UsingTemplateDetails { // Summary of the template diff --git a/packages/taler-util/src/http-impl.qtart.ts b/packages/taler-util/src/http-impl.qtart.ts @@ -118,7 +118,10 @@ export class HttpLibImpl implements HttpRequestLibrary { // Just like WHATWG fetch(), the qjs http client doesn't // really support cancellation, so cancellation here just // means that the result is ignored! - const fetchProm = qjsOs.fetchHttp(url, { + const { + promise: fetchProm, + cancelFn + } = qjsOs.fetchHttp(url, { method, data, headers: headersList, @@ -135,6 +138,7 @@ export class HttpLibImpl implements HttpRequestLibrary { if (opt?.cancellationToken) { cancelCancelledHandler = opt.cancellationToken.onCancelled(() => { + cancelFn(); cancelPromCap.reject(new RequestCancelledError()); }); } diff --git a/packages/taler-util/src/invariants.ts b/packages/taler-util/src/invariants.ts @@ -33,7 +33,7 @@ export class InvariantViolatedError extends Error { * * A violation of this invariant means that the database is inconsistent. */ -export function checkDbInvariant(b: boolean, m?: string): asserts b { +export function checkDbInvariant(b: boolean, m: string): asserts b { if (!b) { if (m) { throw Error(`BUG: database invariant failed (${m})`); diff --git a/packages/taler-util/src/notifications.ts b/packages/taler-util/src/notifications.ts @@ -128,7 +128,7 @@ export enum ObservabilityEventType { TaskStart = "task-start", TaskStop = "task-stop", TaskReset = "task-reset", - ShepherdTaskResult = "sheperd-task-result", + ShepherdTaskResult = "shepherd-task-result", DeclareTaskDependency = "declare-task-dependency", CryptoStart = "crypto-start", CryptoFinishSuccess = "crypto-finish-success", diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -15,7 +15,7 @@ */ import { generateFakeSegwitAddress } from "./bitcoin.js"; -import { Codec, Context, DecodingError, renderContext } from "./codec.js"; +import { Codec, Context, DecodingError, buildCodecForObject, codecForStringURL, renderContext } from "./codec.js"; import { URLSearchParams } from "./url.js"; export type PaytoUri = @@ -291,3 +291,21 @@ export function talerPaytoFromExchangeReserve( return `payto://${proto}/${url.host}${url.pathname}${reservePub}`; } + +/** + * The account letter is all the information + * the merchant backend requires from the + * bank account to check transfer. + * + */ +export type AccountLetter = { + accountURI: PaytoString; + infoURL: string; +}; + +export const codecForAccountLetter = + (): Codec<AccountLetter> => + buildCodecForObject<AccountLetter>() + .property("infoURL", codecForStringURL(true)) + .property("accountURI", codecForPaytoString()) + .build("AccountLetter"); diff --git a/packages/taler-util/src/qtart.ts b/packages/taler-util/src/qtart.ts @@ -17,7 +17,10 @@ export interface QjsHttpOptions { } export interface QjsOsLib { - fetchHttp(url: string, options?: QjsHttpOptions): Promise<QjsHttpResp>; + fetchHttp(url: string, options?: QjsHttpOptions): { + promise: Promise<QjsHttpResp>, + cancelFn: () => number, + }; postMessageToHost(s: string): void; setMessageFromHostHandler(h: (s: string) => void): void; rename(oldPath: string, newPath: string): number; diff --git a/packages/taler-util/src/taler-error-codes.ts b/packages/taler-util/src/taler-error-codes.ts @@ -354,7 +354,7 @@ export enum TalerErrorCode { /** * The backend could not locate a required template to generate an HTML reply. The system administrator should check if the resource files are installed in the correct location and are readable to the service. - * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500). + * Returned with an HTTP status code of #MHD_HTTP_NOT_ACCEPTABLE (406). * (A value of 0 indicates that the error is generated client-side). */ GENERIC_FAILED_TO_LOAD_TEMPLATE = 74, @@ -1945,7 +1945,7 @@ export enum TalerErrorCode { /** - * The payto-URI hash did not match. Hence the request was denied. + * The KYC authorization signature was invalid. Hence the request was denied. * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). * (A value of 0 indicates that the error is generated client-side). */ @@ -2017,6 +2017,22 @@ export enum TalerErrorCode { /** + * The exchange is unaware of the given requirement row. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + EXCHANGE_KYC_CHECK_REQUEST_UNKNOWN = 1939, + + + /** + * The exchange has no account public key to check the KYC authorization signature against. Hence the request was denied. The user should do a wire transfer to the exchange with the KYC authorization key in the subject. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + EXCHANGE_KYC_CHECK_AUTHORIZATION_KEY_UNKNOWN = 1940, + + + /** * The exchange does not know a contract under the given contract public key. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -2105,6 +2121,14 @@ export enum TalerErrorCode { /** + * The product category is not known to the backend. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_GENERIC_CATEGORY_UNKNOWN = 2003, + + + /** * The proposal is not known to the backend. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -2561,6 +2585,14 @@ export enum TalerErrorCode { /** + * Invalid token because it was already used, is expired or not yet valid. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_POST_ORDERS_ID_PAY_TOKEN_INVALID = 2183, + + + /** * The contract hash does not match the given order ID. * Returned with an HTTP status code of #MHD_HTTP_BAD_REQUEST (400). * (A value of 0 indicates that the error is generated client-side). @@ -2921,6 +2953,14 @@ export enum TalerErrorCode { /** + * A token family referenced in this order is either expired or not valid yet. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_ORDERS_TOKEN_FAMILY_NOT_VALID = 2534, + + + /** * The exchange says it does not know this transfer. * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). * (A value of 0 indicates that the error is generated client-side). @@ -3057,6 +3097,14 @@ export enum TalerErrorCode { /** + * A category with the same name exists already. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + MERCHANT_PRIVATE_POST_CATEGORIES_CONFLICT_CATEGORY_EXISTS = 2651, + + + /** * The update would have reduced the total amount of product lost, which is not allowed. * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). * (A value of 0 indicates that the error is generated client-side). @@ -3233,6 +3281,22 @@ export enum TalerErrorCode { /** + * The auditor refused the connection due to a lack of authorization. + * Returned with an HTTP status code of #MHD_HTTP_UNAUTHORIZED (401). + * (A value of 0 indicates that the error is generated client-side). + */ + AUDITOR_GENERIC_UNAUTHORIZED = 3001, + + + /** + * This method is not allowed here. + * Returned with an HTTP status code of #MHD_HTTP_METHOD_NOT_ALLOWED (405). + * (A value of 0 indicates that the error is generated client-side). + */ + AUDITOR_GENERIC_METHOD_NOT_ALLOWED = 3002, + + + /** * The signature from the exchange on the deposit confirmation is invalid. * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). * (A value of 0 indicates that the error is generated client-side). @@ -3633,6 +3697,22 @@ export enum TalerErrorCode { /** + * Specified amount will not work for this withdrawal. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_AMOUNT_DIFFERS = 5148, + + + /** + * The backend requires an amount to be specified. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + BANK_AMOUNT_REQUIRED = 5149, + + + /** * The sync service failed find the account in its database. * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). * (A value of 0 indicates that the error is generated client-side). @@ -4049,6 +4129,14 @@ export enum TalerErrorCode { /** + * A wallet-core request failed because the user needs to first accept the exchange's terms of service. + * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). + * (A value of 0 indicates that the error is generated client-side). + */ + WALLET_EXCHANGE_TOS_NOT_ACCEPTED = 7037, + + + /** * We encountered a timeout with our payment backend. * Returned with an HTTP status code of #MHD_HTTP_GATEWAY_TIMEOUT (504). * (A value of 0 indicates that the error is generated client-side). @@ -4609,6 +4697,62 @@ export enum TalerErrorCode { /** + * The Donau is not aware of the donation unit requested for the operation. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_GENERIC_DONATION_UNIT_UNKNOWN = 8611, + + + /** + * The Donau failed to talk to the process responsible for its private donation unit keys or the helpers had no donation units (properly) configured. + * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONATION_UNIT_HELPER_UNAVAILABLE = 8612, + + + /** + * The Donau failed to talk to the process responsible for its private signing keys. + * Returned with an HTTP status code of #MHD_HTTP_BAD_GATEWAY (502). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_SIGNKEY_HELPER_UNAVAILABLE = 8613, + + + /** + * The response from the online signing key helper process was malformed. + * Returned with an HTTP status code of #MHD_HTTP_INTERNAL_SERVER_ERROR (500). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_SIGNKEY_HELPER_BUG = 8614, + + + /** + * The number of segments included in the URI does not match the number of segments expected by the endpoint. + * Returned with an HTTP status code of #MHD_HTTP_NOT_FOUND (404). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_GENERIC_WRONG_NUMBER_OF_SEGMENTS = 8615, + + + /** + * The signature of the donation receipt is not valid. + * Returned with an HTTP status code of #MHD_HTTP_FORBIDDEN (403). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONATION_RECEIPT_SIGNATURE_INVALID = 8616, + + + /** + * The client re-used a unique donor identifier nonce, which is not allowed. + * Returned with an HTTP status code of #MHD_HTTP_CONFLICT (409). + * (A value of 0 indicates that the error is generated client-side). + */ + DONAU_DONOR_IDENTIFIER_NONCE_REUSE = 8617, + + + /** * A generic error happened in the LibEuFin nexus. See the enclose details JSON for more information. * Returned with an HTTP status code of #MHD_HTTP_UNINITIALIZED (0). * (A value of 0 indicates that the error is generated client-side). diff --git a/packages/taler-util/src/taler-types.ts b/packages/taler-util/src/taler-types.ts @@ -978,7 +978,7 @@ export class WithdrawOperationStatusResponse { aborted: boolean; - amount: string; + amount: string | undefined; sender_wire?: string; @@ -1557,7 +1557,7 @@ export const codecForWithdrawOperationStatusResponse = .property("selection_done", codecForBoolean()) .property("transfer_done", codecForBoolean()) .property("aborted", codecForBoolean()) - .property("amount", codecForString()) + .property("amount", codecOptional(codecForString())) .property("sender_wire", codecOptional(codecForString())) .property("suggested_exchange", codecOptional(codecForString())) .property("confirm_transfer_url", codecOptional(codecForString())) diff --git a/packages/taler-util/src/transactions-types.ts b/packages/taler-util/src/transactions-types.ts @@ -105,8 +105,11 @@ export enum TransactionMajorState { Done = "done", Aborting = "aborting", Aborted = "aborted", - Suspended = "suspended", Dialog = "dialog", + Finalizing = "finalizing", + // Plain suspended is always a suspended pending state. + Suspended = "suspended", + SuspendedFinalizing = "suspended-finalizing", SuspendedAborting = "suspended-aborting", Failed = "failed", Expired = "expired", @@ -324,7 +327,7 @@ export interface TransactionWithdrawal extends TransactionCommon { /** * Exchange of the withdrawal. */ - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; /** * Amount that got subtracted from the reserve balance. diff --git a/packages/taler-util/src/wallet-types.ts b/packages/taler-util/src/wallet-types.ts @@ -54,7 +54,7 @@ import { canonicalizeBaseUrl, } from "./index.js"; import { VersionMatchResult } from "./libtool-version.js"; -import { PaytoUri } from "./payto.js"; +import { PaytoString, PaytoUri, codecForPaytoString } from "./payto.js"; import { AgeCommitmentProof } from "./taler-crypto.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { @@ -229,11 +229,13 @@ interface GetPlanForWalletInitiatedOperation { export interface ConvertAmountRequest { amount: AmountString; type: TransactionAmountMode; + depositPaytoUri: PaytoString; } export const codecForConvertAmountRequest = buildCodecForObject<ConvertAmountRequest>() .property("amount", codecForAmountString()) + .property("depositPaytoUri", codecForPaytoString()) .property( "type", codecForEither( @@ -663,11 +665,11 @@ export interface CoinDumpJson { withdrawal_reserve_pub: string | undefined; coin_status: CoinStatus; spend_allocation: - | { - id: string; - amount: AmountString; - } - | undefined; + | { + id: string; + amount: AmountString; + } + | undefined; /** * Information about the age restriction */ @@ -801,7 +803,7 @@ export const codecForPreparePayResultPaymentPossible = ) .build("PreparePayResultPaymentPossible"); -export interface BalanceDetails { } +export interface BalanceDetails {} /** * Detailed reason for why the wallet's balance is insufficient. @@ -984,9 +986,14 @@ export interface PreparePayResultAlreadyConfirmed { export interface BankWithdrawDetails { status: WithdrawalOperationStatus; - amount: AmountJson; + currency: string; + amount: AmountJson | undefined; + editableAmount: boolean; + maxAmount: AmountJson | undefined; + wireFee: AmountJson | undefined; senderWire?: string; - suggestedExchange?: string; + exchange?: string; + editableExchange: boolean; confirmTransferUrl?: string; wireTypes: string[]; operationId: string; @@ -1331,6 +1338,7 @@ export enum ExchangeTosStatus { Pending = "pending", Proposed = "proposed", Accepted = "accepted", + MissingTos = "missing-tos", } export enum ExchangeEntryStatus { @@ -1846,18 +1854,16 @@ export interface GetWithdrawalDetailsForAmountRequest { export interface PrepareBankIntegratedWithdrawalRequest { talerWithdrawUri: string; - selectedExchange?: string; } export const codecForPrepareBankIntegratedWithdrawalRequest = (): Codec<PrepareBankIntegratedWithdrawalRequest> => buildCodecForObject<PrepareBankIntegratedWithdrawalRequest>() .property("talerWithdrawUri", codecForString()) - .property("selectedExchange", codecOptional(codecForString())) .build("PrepareBankIntegratedWithdrawalRequest"); export interface PrepareBankIntegratedWithdrawalResponse { - transactionId?: string; + transactionId: TransactionIdStr; info: WithdrawUriInfoResponse; } @@ -1883,6 +1889,13 @@ export interface AcceptBankIntegratedWithdrawalRequest { talerWithdrawUri: string; exchangeBaseUrl: string; forcedDenomSel?: ForcedDenomSel; + /** + * Amount to withdraw. + * If the bank's withdrawal operation uses a fixed amount, + * this field must either be left undefined or its value must match + * the amount from the withdrawal operation. + */ + amount?: AmountString; restrictAge?: number; } @@ -1892,6 +1905,7 @@ export const codecForAcceptBankIntegratedWithdrawalRequest = .property("exchangeBaseUrl", codecForCanonBaseUrl()) .property("talerWithdrawUri", codecForString()) .property("forcedDenomSel", codecForAny()) + .property("amount", codecOptional(codecForAmountString())) .property("restrictAge", codecOptional(codecForNumber())) .build("AcceptBankIntegratedWithdrawalRequest"); @@ -2047,7 +2061,7 @@ export interface CheckPayTemplateRequest { export type CheckPayTemplateReponse = { templateDetails: TalerMerchantApi.WalletTemplateDetails; supportedCurrencies: string[]; -} +}; export const codecForCheckPayTemplateRequest = (): Codec<CheckPayTemplateRequest> => @@ -2352,8 +2366,13 @@ export interface WithdrawUriInfoResponse { operationId: string; status: WithdrawalOperationStatus; confirmTransferUrl?: string; - amount: AmountString; + currency: string; + amount: AmountString | undefined; + editableAmount: boolean; + maxAmount: AmountString | undefined; + wireFee: AmountString | undefined; defaultExchangeBaseUrl?: string; + editableExchange: boolean; possibleExchanges: ExchangeListItem[]; } @@ -2371,7 +2390,12 @@ export const codecForWithdrawUriInfoResponse = codecForConstString("confirmed"), ), ) - .property("amount", codecForAmountString()) + .property("amount", codecOptional(codecForAmountString())) + .property("maxAmount", codecOptional(codecForAmountString())) + .property("wireFee", codecOptional(codecForAmountString())) + .property("currency", codecForString()) + .property("editableAmount", codecForBoolean()) + .property("editableExchange", codecForBoolean()) .property("defaultExchangeBaseUrl", codecOptional(codecForCanonBaseUrl())) .property("possibleExchanges", codecForList(codecForExchangeListItem())) .build("WithdrawUriInfoResponse"); diff --git a/packages/taler-wallet-cli/debian/changelog b/packages/taler-wallet-cli/debian/changelog @@ -1,3 +1,27 @@ +taler-wallet-cli (0.11.4) unstable; urgency=low + + * Release 0.11.4 + + -- Florian Dold <dold@taler.net> Mon, 10 Jun 2024 19:57:55 +0200 + +taler-wallet-cli (0.11.3) unstable; urgency=low + + * Release 0.11.3 + + -- Florian Dold <dold@taler.net> Fri, 07 Jun 2024 19:12:44 +0200 + +taler-wallet-cli (0.11.2) unstable; urgency=low + + * Release 0.11.2 + + -- Florian Dold <dold@taler.net> Wed, 05 Jun 2024 20:17:56 +0200 + +taler-wallet-cli (0.11.1) unstable; urgency=low + + * Release 0.11.1 + + -- Florian Dold <dold@taler.net> Mon, 27 May 2024 14:46:35 -0600 + taler-wallet-cli (0.10.7) unstable; urgency=low * Release 0.10.7 diff --git a/packages/taler-wallet-cli/package.json b/packages/taler-wallet-cli/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-cli", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts @@ -1231,6 +1231,16 @@ advancedCli }); advancedCli + .subcommand("resetAllRetries", "reset-all-retries", { + help: "Reset all retry counters.", + }) + .action(async (args) => { + await withWallet(args, { lazyTaskLoop: true }, async (wallet) => { + await wallet.client.call(WalletApiOperation.TestingResetAllRetries, {}); + }); + }); + +advancedCli .subcommand("tasks", "tasks", { help: "Show active wallet-core tasks.", }) diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-core", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-core/src/backup/index.ts b/packages/taler-wallet-core/src/backup/index.ts @@ -805,9 +805,10 @@ async function backupRecoveryTheirs( let backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); - checkDbInvariant(!!backupStateEntry); + checkDbInvariant(!!backupStateEntry, `no backup entry`); checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, ); backupStateEntry.value.lastBackupNonce = undefined; backupStateEntry.value.lastBackupTimestamp = undefined; @@ -913,7 +914,10 @@ export async function provideBackupState( }, ); if (bs) { - checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); + checkDbInvariant( + bs.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, + ); return bs.value; } // We need to generate the key outside of the transaction @@ -941,6 +945,7 @@ export async function provideBackupState( } checkDbInvariant( backupStateEntry.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, ); return backupStateEntry.value; }); @@ -952,7 +957,10 @@ export async function getWalletBackupState( ): Promise<WalletBackupConfState> { const bs = await tx.config.get(ConfigRecordKey.WalletBackupState); checkDbInvariant(!!bs, "wallet backup state should be in DB"); - checkDbInvariant(bs.key === ConfigRecordKey.WalletBackupState); + checkDbInvariant( + bs.key === ConfigRecordKey.WalletBackupState, + `backup entry inconsistent`, + ); return bs.value; } @@ -962,7 +970,7 @@ export async function setWalletDeviceId( ): Promise<void> { await provideBackupState(wex); await wex.db.runReadWriteTx({ storeNames: ["config"] }, async (tx) => { - let backupStateEntry: ConfigRecord | undefined = await tx.config.get( + const backupStateEntry: ConfigRecord | undefined = await tx.config.get( ConfigRecordKey.WalletBackupState, ); if ( diff --git a/packages/taler-wallet-core/src/balance.ts b/packages/taler-wallet-core/src/balance.ts @@ -69,8 +69,8 @@ import { ExchangeRestrictionSpec, findMatchingWire } from "./coinSelection.js"; import { DepositOperationStatus, ExchangeEntryDbRecordStatus, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, PeerPushDebitStatus, RefreshGroupRecord, RefreshOperationStatus, @@ -304,8 +304,8 @@ export async function getBalancesInsideTransaction( const balanceStore: BalancesStore = new BalancesStore(wex, tx); const keyRangeActive = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.exchanges.iter().forEachAsync(async (ex) => { @@ -379,6 +379,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in kyc state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingKyc(currency, wg.exchangeBaseUrl); break; @@ -389,6 +393,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in aml state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingAml(currency, wg.exchangeBaseUrl); break; @@ -408,6 +416,10 @@ export async function getBalancesInsideTransaction( wg.denomsSel !== undefined, "wg in confirmed state should have been initialized", ); + checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "wg in kyc state should have been initialized", + ); const currency = Amounts.currencyOf(wg.denomsSel.totalCoinValue); await balanceStore.setFlagIncomingConfirmation( currency, diff --git a/packages/taler-wallet-core/src/coinSelection.ts b/packages/taler-wallet-core/src/coinSelection.ts @@ -691,7 +691,7 @@ export function checkAccountRestriction( switch (myRestriction.type) { case "deny": return { ok: false }; - case "regex": + case "regex": { const regex = new RegExp(myRestriction.payto_regex); if (!regex.test(paytoUri)) { return { @@ -700,6 +700,7 @@ export function checkAccountRestriction( hintI18n: myRestriction.human_hint_i18n, }; } + } } } return { @@ -909,7 +910,7 @@ async function selectPayCandidates( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); if (denom.isRevoked) { logger.trace("denom is revoked"); continue; diff --git a/packages/taler-wallet-core/src/common.ts b/packages/taler-wallet-core/src/common.ts @@ -31,6 +31,8 @@ import { ExchangeUpdateStatus, Logger, RefreshReason, + TalerError, + TalerErrorCode, TalerErrorDetail, TalerPreciseTimestamp, TalerProtocolTimestamp, @@ -57,11 +59,11 @@ import { PurchaseRecord, RecoupGroupRecord, RefreshGroupRecord, - RewardRecord, WalletDbReadWriteTransaction, WithdrawalGroupRecord, timestampPreciseToDb, } from "./db.js"; +import { ReadyExchangeSummary } from "./exchanges.js"; import { createRefreshGroup } from "./refresh.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; @@ -121,7 +123,10 @@ export async function makeCoinAvailable( coinRecord.exchangeBaseUrl, coinRecord.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coinRecord.denomPubHash}`, + ); const ageRestriction = coinRecord.maxAge; let car = await tx.coinAvailability.get([ coinRecord.exchangeBaseUrl, @@ -175,13 +180,19 @@ export async function spendCoins( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom); + checkDbInvariant( + !!denom, + `denomination of a coin is missing hash: ${coin.denomPubHash}`, + ); const coinAvailability = await tx.coinAvailability.get([ coin.exchangeBaseUrl, coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAvailability); + checkDbInvariant( + !!coinAvailability, + `age denom info is missing for ${coin.maxAge}`, + ); const contrib = csi.contributions[i]; if (coin.status !== CoinStatus.Fresh) { const alloc = coin.spendAllocation; @@ -213,7 +224,6 @@ export async function spendCoins( amount: Amounts.stringify(remaining.amount), coinPub: coin.coinPub, }); - checkDbInvariant(!!coinAvailability); if (coinAvailability.freshCoinCount === 0) { throw Error( `invalid coin count ${coinAvailability.freshCoinCount} in DB`, @@ -258,6 +268,9 @@ export enum TombstoneTag { export function getExchangeTosStatusFromRecord( exchange: ExchangeEntryRecord, ): ExchangeTosStatus { + if (exchange.tosCurrentEtag == null) { + return ExchangeTosStatus.MissingTos; + } if (!exchange.tosAcceptedEtag) { return ExchangeTosStatus.Proposed; } @@ -558,6 +571,28 @@ export function getAutoRefreshExecuteThreshold(d: { } /** + * Type and schema definitions for pending tasks in the wallet. + * + * These are only used internally, and are not part of the stable public + * interface to the wallet. + */ + +export enum PendingTaskType { + ExchangeUpdate = "exchange-update", + Purchase = "purchase", + Refresh = "refresh", + Recoup = "recoup", + RewardPickup = "reward-pickup", + Withdraw = "withdraw", + Deposit = "deposit", + Backup = "backup", + PeerPushDebit = "peer-push-debit", + PeerPullCredit = "peer-pull-credit", + PeerPushCredit = "peer-push-credit", + PeerPullDebit = "peer-pull-debit", +} + +/** * Parsed representation of task identifiers. */ export type ParsedTaskIdentifier = @@ -660,9 +695,6 @@ export namespace TaskIdentifiers { exchBaseUrl, )}` as TaskIdStr; } - export function forTipPickup(tipRecord: RewardRecord): TaskIdStr { - return `${PendingTaskType.RewardPickup}:${tipRecord.walletRewardId}` as TaskIdStr; - } export function forRefresh( refreshGroupRecord: RefreshGroupRecord, ): TaskIdStr { @@ -747,28 +779,6 @@ export interface TransactionContext { deleteTransaction(): Promise<void>; } -/** - * Type and schema definitions for pending tasks in the wallet. - * - * These are only used internally, and are not part of the stable public - * interface to the wallet. - */ - -export enum PendingTaskType { - ExchangeUpdate = "exchange-update", - Purchase = "purchase", - Refresh = "refresh", - Recoup = "recoup", - RewardPickup = "reward-pickup", - Withdraw = "withdraw", - Deposit = "deposit", - Backup = "backup", - PeerPushDebit = "peer-push-debit", - PeerPullCredit = "peer-pull-credit", - PeerPushCredit = "peer-push-credit", - PeerPullDebit = "peer-pull-debit", -} - declare const __taskIdStr: unique symbol; export type TaskIdStr = string & { [__taskIdStr]: true }; @@ -799,7 +809,7 @@ export async function genericWaitForState( flag.raise(); } }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { + const unregisterOnCancelled = wex.cancellationToken.onCancelled((reason) => { cancelNotif(); flag.raise(); }); @@ -819,5 +829,25 @@ export async function genericWaitForState( } catch (e) { unregisterOnCancelled(); cancelNotif(); + throw e; + } +} + +export function requireExchangeTosAcceptedOrThrow( + exchange: ReadyExchangeSummary, +): void { + switch (exchange.tosStatus) { + case ExchangeTosStatus.Accepted: + case ExchangeTosStatus.MissingTos: + break; + default: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_TOS_NOT_ACCEPTED, + { + exchangeBaseUrl: exchange.exchangeBaseUrl, + currentEtag: exchange.tosCurrentEtag, + tosStatus: exchange.tosStatus, + }, + ); } } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts @@ -248,6 +248,9 @@ export function timestampOptionalAbsoluteFromDb( * 0x0103_nnnn: aborting * 0x0110_nnnn: suspended * 0x0113_nnnn: suspended-aborting + * a=2: finalizing + * 0x0200_nnnn: finalizing + * 0x0210_nnnn: suspended-finalizing * a=5: final * 0x0500_nnnn: done * 0x0501_nnnn: failed @@ -260,12 +263,12 @@ export function timestampOptionalAbsoluteFromDb( /** * First possible operation status in the active range (inclusive). */ -export const OPERATION_STATUS_ACTIVE_FIRST = 0x0100_0000; +export const OPERATION_STATUS_NONFINAL_FIRST = 0x0100_0000; /** * LAST possible operation status in the active range (inclusive). */ -export const OPERATION_STATUS_ACTIVE_LAST = 0x0113_ffff; +export const OPERATION_STATUS_NONFINAL_LAST = 0x0210_ffff; /** * Status of a withdrawal. @@ -395,6 +398,8 @@ export interface ReserveBankInfo { timestampBankConfirmed: DbPreciseTimestamp | undefined; wireTypes: string[] | undefined; + + currency: string | undefined; } /** @@ -918,92 +923,6 @@ export interface CoinAllocation { amount: AmountString; } -/** - * Status of a reward we got from a merchant. - */ -export interface RewardRecord { - /** - * Has the user accepted the tip? Only after the tip has been accepted coins - * withdrawn from the tip may be used. - */ - acceptedTimestamp: DbPreciseTimestamp | undefined; - - /** - * The tipped amount. - */ - rewardAmountRaw: AmountString; - - /** - * Effect on the balance (including fees etc). - */ - rewardAmountEffective: AmountString; - - /** - * Timestamp, the tip can't be picked up anymore after this deadline. - */ - rewardExpiration: DbProtocolTimestamp; - - /** - * The exchange that will sign our coins, chosen by the merchant. - */ - exchangeBaseUrl: string; - - /** - * Base URL of the merchant that is giving us the tip. - */ - merchantBaseUrl: string; - - /** - * Denomination selection made by the wallet for picking up - * this tip. - * - * FIXME: Put this into some DenomSelectionCacheRecord instead of - * storing it here! - */ - denomsSel: DenomSelectionState; - - denomSelUid: string; - - /** - * Tip ID chosen by the wallet. - */ - walletRewardId: string; - - /** - * Secret seed used to derive planchets for this tip. - */ - secretSeed: string; - - /** - * The merchant's identifier for this reward. - */ - merchantRewardId: string; - - createdTimestamp: DbPreciseTimestamp; - - /** - * The url to be redirected after the tip is accepted. - */ - next_url: string | undefined; - - /** - * Timestamp for when the wallet finished picking up the tip - * from the merchant. - */ - pickedUpTimestamp: DbPreciseTimestamp | undefined; - - status: RewardRecordStatus; -} - -export enum RewardRecordStatus { - PendingPickup = 0x0100_0000, - SuspendedPickup = 0x0110_0000, - DialogAccept = 0x0101_0000, - Done = 0x0500_0000, - Aborted = 0x0500_0000, - Failed = 0x0501_000, -} - export enum RefreshCoinStatus { Pending = 0x0100_0000, Finished = 0x0500_0000, @@ -1178,10 +1097,15 @@ export enum PurchaseStatus { /** * Query for refund (until auto-refund deadline is reached). + * + * Legacy state for compatibility. */ PendingQueryingAutoRefund = 0x0100_0004, SuspendedQueryingAutoRefund = 0x0110_0004, + FinalizingQueryingAutoRefund = 0x0200_0001, + SuspendedFinalizingQueryingAutoRefund = 0x0210_0001, + PendingAcceptRefund = 0x0100_0005, SuspendedPendingAcceptRefund = 0x0110_0005, @@ -1197,11 +1121,6 @@ export enum PurchaseStatus { DialogShared = 0x0101_0001, /** - * The user has rejected the proposal. - */ - AbortedProposalRefused = 0x0503_0000, - - /** * Downloading or processing the proposal has failed permanently. */ FailedClaim = 0x0501_0000, @@ -1224,13 +1143,18 @@ export enum PurchaseStatus { DoneRepurchaseDetected = 0x0500_0001, /** - * The payment has been aborted. + * The user has rejected the proposal. */ - AbortedIncompletePayment = 0x0503_0000, + AbortedProposalRefused = 0x0503_0000, AbortedRefunded = 0x0503_0001, AbortedOrderDeleted = 0x0503_0002, + + /** + * The payment has been aborted. + */ + AbortedIncompletePayment = 0x0503_0003, } /** @@ -1439,6 +1363,7 @@ export interface WgInfoBankIntegrated { * a Taler-integrated bank. */ bankInfo: ReserveBankInfo; + /** * Info about withdrawal accounts, possibly including currency conversion. */ @@ -1530,7 +1455,7 @@ export interface WithdrawalGroupRecord { * The exchange base URL that we're withdrawing from. * (Redundantly stored, as the reserve record also has this info.) */ - exchangeBaseUrl: string; + exchangeBaseUrl?: string; /** * When was the withdrawal operation started started? @@ -1976,7 +1901,7 @@ export enum PeerPullPaymentCreditStatus { SuspendedCreatePurse = 0x0110_0000, SuspendedReady = 0x0110_0001, SuspendedMergeKycRequired = 0x0110_0002, - SuspendedWithdrawing = 0x0110_0000, + SuspendedWithdrawing = 0x0110_0003, SuspendedAbortingDeletePurse = 0x0113_0000, @@ -2630,9 +2555,10 @@ export const WalletStoresV1 = { ]), }, ), + // Just a tombstone at this point. rewards: describeStore( "rewards", - describeContents<RewardRecord>({ keyPath: "walletRewardId" }), + describeContents<any>({ keyPath: "walletRewardId" }), { byMerchantTipIdAndBaseUrl: describeIndex("byMerchantRewardIdAndBaseUrl", [ "merchantRewardId", @@ -2940,6 +2866,8 @@ export interface DbDump { }; } +const logger = new Logger("db.ts"); + export async function exportSingleDb( idb: IDBFactory, dbName: string, @@ -3081,8 +3009,6 @@ export interface FixupDescription { */ export const walletDbFixups: FixupDescription[] = []; -const logger = new Logger("db.ts"); - export async function applyFixups( db: DbAccess<typeof WalletStoresV1>, ): Promise<void> { diff --git a/packages/taler-wallet-core/src/dbless.ts b/packages/taler-wallet-core/src/dbless.ts @@ -123,7 +123,7 @@ export async function topupReserveWithBank(args: TopupReserveWithBankArgs) { ); const bankInfo = await getBankWithdrawalInfo(http, wopi.taler_withdraw_uri); const bankStatusUrl = getBankStatusUrl(wopi.taler_withdraw_uri); - if (!bankInfo.suggestedExchange) { + if (!bankInfo.exchange) { throw Error("no suggested exchange"); } const plainPaytoUris = diff --git a/packages/taler-wallet-core/src/deposits.ts b/packages/taler-wallet-core/src/deposits.ts @@ -387,11 +387,19 @@ export function computeDepositTransactionActions( case DepositOperationStatus.Finished: return [TransactionAction.Delete]; case DepositOperationStatus.PendingDeposit: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case DepositOperationStatus.SuspendedDeposit: return [TransactionAction.Resume]; case DepositOperationStatus.Aborting: - return [TransactionAction.Fail, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Fail, + TransactionAction.Suspend, + ]; case DepositOperationStatus.Aborted: return [TransactionAction.Delete]; case DepositOperationStatus.Failed: @@ -399,9 +407,17 @@ export function computeDepositTransactionActions( case DepositOperationStatus.SuspendedAborting: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.PendingKyc: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case DepositOperationStatus.PendingTrack: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case DepositOperationStatus.SuspendedKyc: return [TransactionAction.Resume, TransactionAction.Fail]; case DepositOperationStatus.SuspendedTrack: @@ -441,7 +457,7 @@ async function refundDepositGroup( { storeNames: ["coins"] }, async (tx) => { const coinRecord = await tx.coins.get(coinPub); - checkDbInvariant(!!coinRecord); + checkDbInvariant(!!coinRecord, `coin ${coinPub} not found in DB`); return coinRecord.exchangeBaseUrl; }, ); diff --git a/packages/taler-wallet-core/src/exchanges.ts b/packages/taler-wallet-core/src/exchanges.ts @@ -28,7 +28,6 @@ import { AgeRestriction, Amount, Amounts, - AsyncFlag, CancellationToken, CoinRefreshRequest, CoinStatus, @@ -53,6 +52,7 @@ import { GetExchangeResourcesResponse, GetExchangeTosResult, GlobalFees, + HttpStatusCode, LibtoolVersion, Logger, NotificationType, @@ -79,6 +79,7 @@ import { WireInfo, assertUnreachable, checkDbInvariant, + checkLogicInvariant, codecForExchangeKeysJson, durationMul, encodeCrock, @@ -93,6 +94,8 @@ import { getExpiry, readSuccessResponseJsonOrThrow, readSuccessResponseTextOrThrow, + readTalerErrorResponse, + throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { PendingTaskType, @@ -103,6 +106,7 @@ import { TransactionContext, computeDbBackoff, constructTaskIdentifier, + genericWaitForState, getAutoRefreshExecuteThreshold, getExchangeEntryStatusFromRecord, getExchangeState, @@ -861,6 +865,41 @@ async function downloadExchangeKeysInfo( }; } +type TosMetaResult = { type: "not-found" } | { type: "ok"; etag: string }; + +/** + * Download metadata about an exchange's terms of service. + */ +async function downloadTosMeta( + wex: WalletExecutionContext, + exchangeBaseUrl: string, +): Promise<TosMetaResult> { + logger.trace(`downloading exchange tos metadata for ${exchangeBaseUrl}`); + const reqUrl = new URL("terms", exchangeBaseUrl); + + // FIXME: We can/should make a HEAD request here. + // Not sure if qtart supports it at the moment. + const resp = await wex.http.fetch(reqUrl.href, { + cancellationToken: wex.cancellationToken, + }); + + switch (resp.status) { + case HttpStatusCode.NotFound: + case HttpStatusCode.NotImplemented: + return { type: "not-found" }; + case HttpStatusCode.Ok: + break; + default: + throwUnexpectedRequestError(resp, await readTalerErrorResponse(resp)); + } + + const etag = resp.headers.get("etag") || "unknown"; + return { + type: "ok", + etag, + }; +} + async function downloadTosFromAcceptedFormat( wex: WalletExecutionContext, baseUrl: string, @@ -977,9 +1016,7 @@ async function startUpdateExchangeEntry( wex.ws.exchangeCache.clear(); await tx.exchanges.put(r); const newExchangeState = getExchangeState(r); - // Reset retries for updating the exchange entry. const taskId = TaskIdentifiers.forExchangeUpdate(r); - await tx.operationRetries.delete(taskId); return { oldExchangeState, newExchangeState, taskId }; }, ); @@ -989,6 +1026,8 @@ async function startUpdateExchangeEntry( newExchangeState: newExchangeState, oldExchangeState: oldExchangeState, }); + logger.info(`start update ${exchangeBaseUrl} task ${taskId}`); + await wex.taskScheduler.resetTaskRetries(taskId); } @@ -1008,132 +1047,6 @@ export interface ReadyExchangeSummary { scopeInfo: ScopeInfo; } -async function internalWaitReadyExchange( - wex: WalletExecutionContext, - canonUrl: string, - exchangeNotifFlag: AsyncFlag, - options: { - cancellationToken?: CancellationToken; - forceUpdate?: boolean; - expectedMasterPub?: string; - } = {}, -): Promise<ReadyExchangeSummary> { - const operationId = constructTaskIdentifier({ - tag: PendingTaskType.ExchangeUpdate, - exchangeBaseUrl: canonUrl, - }); - while (true) { - if (wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - logger.info(`waiting for ready exchange ${canonUrl}`); - const { exchange, exchangeDetails, retryInfo, scopeInfo } = - await wex.db.runReadOnlyTx( - { - storeNames: [ - "exchanges", - "exchangeDetails", - "operationRetries", - "globalCurrencyAuditors", - "globalCurrencyExchanges", - ], - }, - async (tx) => { - const exchange = await tx.exchanges.get(canonUrl); - const exchangeDetails = await getExchangeRecordsInternal( - tx, - canonUrl, - ); - const retryInfo = await tx.operationRetries.get(operationId); - let scopeInfo: ScopeInfo | undefined = undefined; - if (exchange && exchangeDetails) { - scopeInfo = await internalGetExchangeScopeInfo(tx, exchangeDetails); - } - return { exchange, exchangeDetails, retryInfo, scopeInfo }; - }, - ); - - if (!exchange) { - throw Error("exchange entry does not exist anymore"); - } - - let ready = false; - - switch (exchange.updateStatus) { - case ExchangeEntryDbUpdateStatus.Ready: - ready = true; - break; - case ExchangeEntryDbUpdateStatus.ReadyUpdate: - // If the update is forced, - // we wait until we're in a full "ready" state, - // as we're not happy with the stale information. - if (!options.forceUpdate) { - ready = true; - } - break; - case ExchangeEntryDbUpdateStatus.UnavailableUpdate: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - default: { - if (retryInfo) { - throw TalerError.fromDetail( - TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, - { - exchangeBaseUrl: canonUrl, - innerError: retryInfo?.lastError, - }, - ); - } - } - } - - if (!ready) { - logger.info("waiting for exchange update notification"); - await exchangeNotifFlag.wait(); - logger.info("done waiting for exchange update notification"); - exchangeNotifFlag.reset(); - continue; - } - - if (!exchangeDetails) { - throw Error("invariant failed"); - } - - if (!scopeInfo) { - throw Error("invariant failed"); - } - - const res: ReadyExchangeSummary = { - currency: exchangeDetails.currency, - exchangeBaseUrl: canonUrl, - masterPub: exchangeDetails.masterPublicKey, - tosStatus: getExchangeTosStatusFromRecord(exchange), - tosAcceptedEtag: exchange.tosAcceptedEtag, - wireInfo: exchangeDetails.wireInfo, - protocolVersionRange: exchangeDetails.protocolVersionRange, - tosCurrentEtag: exchange.tosCurrentEtag, - tosAcceptedTimestamp: timestampOptionalPreciseFromDb( - exchange.tosAcceptedTimestamp, - ), - scopeInfo, - }; - - if (options.expectedMasterPub) { - if (res.masterPub !== options.expectedMasterPub) { - throw Error( - "public key of the exchange does not match expected public key", - ); - } - } - return res; - } -} - /** * Ensure that a fresh exchange entry exists for the given * exchange base URL. @@ -1155,6 +1068,8 @@ export async function fetchFreshExchange( forceUpdate?: boolean; } = {}, ): Promise<ReadyExchangeSummary> { + logger.info(`fetch fresh ${baseUrl} forced ${options.forceUpdate}`); + if (!options.forceUpdate) { const cachedResp = wex.ws.exchangeCache.get(baseUrl); if (cachedResp) { @@ -1184,39 +1099,131 @@ async function waitReadyExchange( } = {}, ): Promise<ReadyExchangeSummary> { logger.trace(`waiting for exchange ${canonUrl} to become ready`); - // FIXME: We should use Symbol.dispose magic here for cleanup! - const exchangeNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.ExchangeStateTransition && - notif.exchangeBaseUrl === canonUrl - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - exchangeNotifFlag.raise(); - } + const operationId = constructTaskIdentifier({ + tag: PendingTaskType.ExchangeUpdate, + exchangeBaseUrl: canonUrl, }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - exchangeNotifFlag.raise(); + let res: ReadyExchangeSummary | undefined = undefined; + + await genericWaitForState(wex, { + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.ExchangeStateTransition && + notif.exchangeBaseUrl === canonUrl + ); + }, + async checkState(): Promise<boolean> { + const { exchange, exchangeDetails, retryInfo, scopeInfo } = + await wex.db.runReadOnlyTx( + { + storeNames: [ + "exchanges", + "exchangeDetails", + "operationRetries", + "globalCurrencyAuditors", + "globalCurrencyExchanges", + ], + }, + async (tx) => { + const exchange = await tx.exchanges.get(canonUrl); + const exchangeDetails = await getExchangeRecordsInternal( + tx, + canonUrl, + ); + const retryInfo = await tx.operationRetries.get(operationId); + let scopeInfo: ScopeInfo | undefined = undefined; + if (exchange && exchangeDetails) { + scopeInfo = await internalGetExchangeScopeInfo( + tx, + exchangeDetails, + ); + } + return { exchange, exchangeDetails, retryInfo, scopeInfo }; + }, + ); + + if (!exchange) { + throw Error("exchange entry does not exist anymore"); + } + + let ready = false; + + switch (exchange.updateStatus) { + case ExchangeEntryDbUpdateStatus.Ready: + ready = true; + break; + case ExchangeEntryDbUpdateStatus.ReadyUpdate: + // If the update is forced, + // we wait until we're in a full "ready" state, + // as we're not happy with the stale information. + if (!options.forceUpdate) { + ready = true; + } + break; + case ExchangeEntryDbUpdateStatus.UnavailableUpdate: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + default: { + if (retryInfo) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_EXCHANGE_UNAVAILABLE, + { + exchangeBaseUrl: canonUrl, + innerError: retryInfo?.lastError, + }, + ); + } + } + } + + if (!ready) { + return false; + } + + if (!exchangeDetails) { + throw Error("invariant failed"); + } + + if (!scopeInfo) { + throw Error("invariant failed"); + } + + const mySummary: ReadyExchangeSummary = { + currency: exchangeDetails.currency, + exchangeBaseUrl: canonUrl, + masterPub: exchangeDetails.masterPublicKey, + tosStatus: getExchangeTosStatusFromRecord(exchange), + tosAcceptedEtag: exchange.tosAcceptedEtag, + wireInfo: exchangeDetails.wireInfo, + protocolVersionRange: exchangeDetails.protocolVersionRange, + tosCurrentEtag: exchange.tosCurrentEtag, + tosAcceptedTimestamp: timestampOptionalPreciseFromDb( + exchange.tosAcceptedTimestamp, + ), + scopeInfo, + }; + + if (options.expectedMasterPub) { + if (mySummary.masterPub !== options.expectedMasterPub) { + throw Error( + "public key of the exchange does not match expected public key", + ); + } + } + res = mySummary; + return true; + }, }); - try { - const res = await internalWaitReadyExchange( - wex, - canonUrl, - exchangeNotifFlag, - options, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - unregisterOnCancelled(); - cancelNotif(); - } + checkLogicInvariant(!!res); + return res; } function checkPeerPaymentsDisabled( @@ -1359,7 +1366,6 @@ export async function updateExchangeFromUrlHandler( ); refreshCheckNecessary = false; } - if (!(updateNecessary || refreshCheckNecessary)) { logger.trace("update not necessary, running again later"); return TaskRunResult.runAgainAt( @@ -1421,15 +1427,7 @@ export async function updateExchangeFromUrlHandler( logger.trace("finished validating exchange /wire info"); - // We download the text/plain version here, - // because that one needs to exist, and we - // will get the current etag from the response. - const tosDownload = await downloadTosFromAcceptedFormat( - wex, - exchangeBaseUrl, - timeout, - ["text/plain"], - ); + const tosMeta = await downloadTosMeta(wex, exchangeBaseUrl); logger.trace("updating exchange info in database"); @@ -1522,7 +1520,14 @@ export async function updateExchangeFromUrlHandler( }; r.noFees = noFees; r.peerPaymentsDisabled = peerPaymentsDisabled; - r.tosCurrentEtag = tosDownload.tosEtag; + switch (tosMeta.type) { + case "not-found": + r.tosCurrentEtag = undefined; + break; + case "ok": + r.tosCurrentEtag = tosMeta.etag; + break; + } if (existingDetails?.rowId) { newDetails.rowId = existingDetails.rowId; } @@ -1548,7 +1553,10 @@ export async function updateExchangeFromUrlHandler( r.cachebreakNextUpdate = false; await tx.exchanges.put(r); const drRowId = await tx.exchangeDetails.put(newDetails); - checkDbInvariant(typeof drRowId.key === "number"); + checkDbInvariant( + typeof drRowId.key === "number", + "exchange details key is not a number", + ); for (const sk of keysInfo.signingKeys) { // FIXME: validate signing keys before inserting them @@ -2227,10 +2235,12 @@ export async function markExchangeUsed( logger.info(`marking exchange ${exchangeBaseUrl} as used`); const exch = await tx.exchanges.get(exchangeBaseUrl); if (!exch) { + logger.info(`exchange ${exchangeBaseUrl} NOT found`); return { notif: undefined, }; } + const oldExchangeState = getExchangeState(exch); switch (exch.entryStatus) { case ExchangeEntryDbRecordStatus.Ephemeral: diff --git a/packages/taler-wallet-core/src/instructedAmountConversion.ts b/packages/taler-wallet-core/src/instructedAmountConversion.ts @@ -283,7 +283,7 @@ async function getAvailableDenoms( coinAvail.exchangeBaseUrl, coinAvail.denomPubHash, ]); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `denomination of a coin is missing hash: ${coinAvail.denomPubHash}`); if (denom.isRevoked || !denom.isOffered) { continue; } @@ -472,7 +472,7 @@ export async function getMaxDepositAmount( export function getMaxDepositAmountForAvailableCoins( denoms: AvailableCoins, currency: string, -) { +): AmountWithFee { const zero = Amounts.zeroOfCurrency(currency); if (!denoms.list.length) { // no coins in the database @@ -663,8 +663,13 @@ function rankDenominationForWithdrawals( //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomWithdraw).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomWithdraw).quotient; + + const rate1 = Amounts.isZero(d1.denomWithdraw) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d1.value, d1.denomWithdraw).quotient; + const rate2 = Amounts.isZero(d2.denomWithdraw) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d2.value, d2.denomWithdraw).quotient; const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; return ( contribCmp || @@ -719,8 +724,13 @@ function rankDenominationForDeposit( //different exchanges may have different wireFee //ranking should take the relative contribution in the exchange //which is (value - denomFee / fixedFee) - const rate1 = Amounts.divmod(d1.value, d1.denomDeposit).quotient; - const rate2 = Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const rate1 = Amounts.isZero(d1.denomDeposit) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d1.value, d1.denomDeposit).quotient; + const rate2 = Amounts.isZero(d2.denomDeposit) + ? Number.MIN_SAFE_INTEGER + : Amounts.divmod(d2.value, d2.denomDeposit).quotient; + const contribCmp = rate1 === rate2 ? 0 : rate1 < rate2 ? 1 : -1; return ( contribCmp || diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -34,7 +34,6 @@ import { assertUnreachable, AsyncFlag, checkDbInvariant, - CheckPaymentResponse, CheckPayTemplateReponse, CheckPayTemplateRequest, codecForAbortResponse, @@ -342,7 +341,6 @@ export class PayMerchantTransactionContext implements TransactionContext { return; } await tx.purchases.put(purchase); - await tx.operationRetries.delete(this.taskId); const newTxState = computePayMerchantTransactionState(purchase); return { oldTxState, newTxState }; }, @@ -1028,11 +1026,17 @@ async function storeFirstPaySuccess( purchase.merchantPaySig = payResponse.sig; purchase.posConfirmation = payResponse.pos_confirmation; const dl = purchase.download; - checkDbInvariant(!!dl); + checkDbInvariant( + !!dl, + `purchase ${purchase.orderId} without ct downloaded`, + ); const contractTermsRecord = await tx.contractTerms.get( dl.contractTermsHash, ); - checkDbInvariant(!!contractTermsRecord); + checkDbInvariant( + !!contractTermsRecord, + `no contract terms found for purchase ${purchase.orderId}`, + ); const contractData = extractContractData( contractTermsRecord.contractTermsRaw, dl.contractTermsHash, @@ -1625,6 +1629,9 @@ export async function checkPayForTemplate( throw TalerError.fromUncheckedDetail(cfg.detail); } + // FIXME: Put body.currencies *and* body.currency in the set of + // supported currencies. + return { templateDetails, supportedCurrencies: Object.keys(cfg.body.currencies), @@ -2086,6 +2093,7 @@ export async function processPurchase( case PurchaseStatus.PendingPayingReplay: return processPurchasePay(wex, proposalId); case PurchaseStatus.PendingQueryingRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: return processPurchaseQueryRefund(wex, purchase); case PurchaseStatus.PendingQueryingAutoRefund: return processPurchaseAutoRefund(wex, purchase); @@ -2110,6 +2118,7 @@ export async function processPurchase( case PurchaseStatus.SuspendedPendingAcceptRefund: case PurchaseStatus.SuspendedQueryingAutoRefund: case PurchaseStatus.SuspendedQueryingRefund: + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: case PurchaseStatus.FailedAbort: case PurchaseStatus.FailedPaidByOther: return TaskRunResult.finished(); @@ -2155,7 +2164,7 @@ async function processPurchasePay( logger.trace(`paying with session ID ${sessionId}`); const payInfo = purchase.payInfo; - checkDbInvariant(!!payInfo, "payInfo"); + checkDbInvariant(!!payInfo, `purchase ${purchase.orderId} without payInfo`); const download = await expectProposalDownload(wex, purchase); @@ -2487,6 +2496,9 @@ const transitionSuspend: { [PurchaseStatus.PendingQueryingAutoRefund]: { next: PurchaseStatus.SuspendedQueryingAutoRefund, }, + [PurchaseStatus.FinalizingQueryingAutoRefund]: { + next: PurchaseStatus.SuspendedFinalizingQueryingAutoRefund, + }, }; const transitionResume: { @@ -2509,6 +2521,9 @@ const transitionResume: { [PurchaseStatus.SuspendedQueryingAutoRefund]: { next: PurchaseStatus.PendingQueryingAutoRefund, }, + [PurchaseStatus.SuspendedFinalizingQueryingAutoRefund]: { + next: PurchaseStatus.FinalizingQueryingAutoRefund, + }, }; export function computePayMerchantTransactionState( @@ -2637,6 +2652,16 @@ export function computePayMerchantTransactionState( major: TransactionMajorState.Failed, minor: TransactionMinorState.PaidByOther, }; + case PurchaseStatus.FinalizingQueryingAutoRefund: + return { + major: TransactionMajorState.Finalizing, + minor: TransactionMinorState.AutoRefund, + }; + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + return { + major: TransactionMajorState.SuspendedFinalizing, + minor: TransactionMinorState.AutoRefund, + }; default: assertUnreachable(purchaseRecord.purchaseStatus); } @@ -2648,21 +2673,45 @@ export function computePayMerchantTransactionActions( switch (purchaseRecord.purchaseStatus) { // Pending States case PurchaseStatus.PendingDownloadingProposal: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingPaying: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingPayingReplay: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingQueryingAutoRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingQueryingRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case PurchaseStatus.PendingAcceptRefund: // Special "abort" since it goes back to "done". - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; // Suspended Pending States case PurchaseStatus.SuspendedDownloadingProposal: return [TransactionAction.Resume, TransactionAction.Abort]; @@ -2682,14 +2731,18 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Resume, TransactionAction.Abort]; // Aborting States case PurchaseStatus.AbortingWithRefund: - return [TransactionAction.Fail, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Fail, + TransactionAction.Suspend, + ]; case PurchaseStatus.SuspendedAbortingWithRefund: return [TransactionAction.Fail, TransactionAction.Resume]; // Dialog States case PurchaseStatus.DialogProposed: - return []; + return [TransactionAction.Retry]; case PurchaseStatus.DialogShared: - return []; + return [TransactionAction.Retry]; // Final States case PurchaseStatus.AbortedProposalRefused: case PurchaseStatus.AbortedOrderDeleted: @@ -2707,6 +2760,14 @@ export function computePayMerchantTransactionActions( return [TransactionAction.Delete]; case PurchaseStatus.FailedPaidByOther: return [TransactionAction.Delete]; + case PurchaseStatus.FinalizingQueryingAutoRefund: + return [ + TransactionAction.Suspend, + TransactionAction.Retry, + TransactionAction.Delete, + ]; + case PurchaseStatus.SuspendedFinalizingQueryingAutoRefund: + return [TransactionAction.Resume, TransactionAction.Delete]; default: assertUnreachable(purchaseRecord.purchaseStatus); } @@ -2909,8 +2970,12 @@ async function processPurchaseAutoRefund( logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { - return; + switch (p.purchaseStatus) { + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: + break; + default: + return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.Done; @@ -2956,8 +3021,12 @@ async function processPurchaseAutoRefund( logger.warn("purchase does not exist anymore"); return; } - if (p.purchaseStatus !== PurchaseStatus.PendingQueryingAutoRefund) { - return; + switch (p.purchaseStatus) { + case PurchaseStatus.PendingQueryingAutoRefund: + case PurchaseStatus.FinalizingQueryingAutoRefund: + break; + default: + return; } const oldTxState = computePayMerchantTransactionState(p); p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; @@ -2997,7 +3066,7 @@ async function processPurchaseAbortingRefund( for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { const coinPub = payCoinSelection.coinPubs[i]; const coin = await tx.coins.get(coinPub); - checkDbInvariant(!!coin, "expected coin to be present"); + checkDbInvariant(!!coin, `coin not found for ${coinPub}`); abortingCoins.push({ coin_pub: coinPub, contribution: Amounts.stringify(payCoinSelection.coinContributions[i]), @@ -3501,7 +3570,8 @@ async function storeRefunds( if (isAborting) { myPurchase.purchaseStatus = PurchaseStatus.AbortedRefunded; } else if (shouldCheckAutoRefund) { - myPurchase.purchaseStatus = PurchaseStatus.PendingQueryingAutoRefund; + myPurchase.purchaseStatus = + PurchaseStatus.FinalizingQueryingAutoRefund; } else { myPurchase.purchaseStatus = PurchaseStatus.Done; } diff --git a/packages/taler-wallet-core/src/pay-peer-common.ts b/packages/taler-wallet-core/src/pay-peer-common.ts @@ -140,10 +140,10 @@ export async function getMergeReserveInfo( { storeNames: ["exchanges", "reserves"] }, async (tx) => { const ex = await tx.exchanges.get(req.exchangeBaseUrl); - checkDbInvariant(!!ex); + checkDbInvariant(!!ex, `no exchange record for ${req.exchangeBaseUrl}`); if (ex.currentMergeReserveRowId != null) { const reserve = await tx.reserves.get(ex.currentMergeReserveRowId); - checkDbInvariant(!!reserve); + checkDbInvariant(!!reserve, `reserver ${ex.currentMergeReserveRowId} missing in db`); return reserve; } const reserve: ReserveRecord = { @@ -151,7 +151,7 @@ export async function getMergeReserveInfo( reservePub: newReservePair.pub, }; const insertResp = await tx.reserves.put(reserve); - checkDbInvariant(typeof insertResp.key === "number"); + checkDbInvariant(typeof insertResp.key === "number", `reserve key is not a number`); reserve.rowId = insertResp.key; ex.currentMergeReserveRowId = reserve.rowId; await tx.exchanges.put(ex); diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -59,6 +59,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -933,6 +934,11 @@ export async function checkPeerPullPaymentInitiation( Amounts.parseOrThrow(req.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to check pull payment from ${exchangeUrl}, can't select denominations for instructed amount (${req.amount}`, + ); + } logger.trace(`got withdrawal info`); @@ -1021,7 +1027,8 @@ export async function initiatePeerPullPayment( const exchangeBaseUrl = maybeExchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const mergeReserveInfo = await getMergeReserveInfo(wex, { exchangeBaseUrl: exchangeBaseUrl, @@ -1039,7 +1046,10 @@ export async function initiatePeerPullPayment( const withdrawalGroupId = encodeCrock(getRandomBytes(32)); const mergeReserveRowId = mergeReserveInfo.rowId; - checkDbInvariant(!!mergeReserveRowId); + checkDbInvariant( + !!mergeReserveRowId, + `merge reserve for ${exchangeBaseUrl} without rowid`, + ); const contractEncNonce = encodeCrock(getRandomBytes(24)); @@ -1049,6 +1059,11 @@ export async function initiatePeerPullPayment( Amounts.parseOrThrow(req.partialContractTerms.amount), undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to initiate pull payment from ${exchangeBaseUrl}, can't select denominations for instructed amount (${req.partialContractTerms.amount}`, + ); + } const mergeTimestamp = TalerPreciseTimestamp.now(); @@ -1184,15 +1199,31 @@ export function computePeerPullCreditTransactionActions( ): TransactionAction[] { switch (pullCreditRecord.status) { case PeerPullPaymentCreditStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.Done: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.PendingWithdrawing: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPullPaymentCreditStatus.SuspendedCreatePurse: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPullPaymentCreditStatus.SuspendedReady: @@ -1204,7 +1235,11 @@ export function computePeerPullCreditTransactionActions( case PeerPullPaymentCreditStatus.Aborted: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPullPaymentCreditStatus.Failed: return [TransactionAction.Delete]; case PeerPullPaymentCreditStatus.Expired: diff --git a/packages/taler-wallet-core/src/pay-peer-pull-debit.ts b/packages/taler-wallet-core/src/pay-peer-pull-debit.ts @@ -1000,7 +1000,7 @@ export function computePeerPullDebitTransactionActions( ): TransactionAction[] { switch (pullDebitRecord.status) { case PeerPullDebitRecordStatus.DialogProposed: - return []; + return [TransactionAction.Retry, TransactionAction.Delete]; case PeerPullDebitRecordStatus.PendingDeposit: return [TransactionAction.Abort, TransactionAction.Suspend]; case PeerPullDebitRecordStatus.Done: diff --git a/packages/taler-wallet-core/src/pay-peer-push-credit.ts b/packages/taler-wallet-core/src/pay-peer-push-credit.ts @@ -61,6 +61,7 @@ import { TombstoneTag, TransactionContext, constructTaskIdentifier, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { KycPendingInfo, @@ -407,7 +408,8 @@ export async function preparePeerPushCredit( const exchangeBaseUrl = uri.exchangeBaseUrl; - await fetchFreshExchange(wex, exchangeBaseUrl); + const exchange = await fetchFreshExchange(wex, exchangeBaseUrl); + requireExchangeTosAcceptedOrThrow(exchange); const contractPriv = uri.contractPriv; const contractPub = encodeCrock(eddsaGetPublic(decodeCrock(contractPriv))); @@ -459,6 +461,12 @@ export async function preparePeerPushCredit( undefined, ); + if (wi.selectedDenoms.selectedDenoms.length === 0) { + throw Error( + `unable to prepare push credit from ${exchangeBaseUrl}, can't select denominations for instructed amount (${purseStatus.balance}`, + ); + } + const transitionInfo = await wex.db.runReadWriteTx( { storeNames: ["contractTerms", "peerPushCredit"] }, async (tx) => { @@ -872,7 +880,10 @@ export async function processPeerPushCredit( `processing peerPushCredit in state ${peerInc.status.toString(16)}`, ); - checkDbInvariant(!!contractTerms); + checkDbInvariant( + !!contractTerms, + `not contract terms for peer push ${peerPushCreditId}`, + ); switch (peerInc.status) { case PeerPushCreditStatus.PendingMergeKycRequired: { @@ -1011,15 +1022,27 @@ export function computePeerPushCreditTransactionActions( ): TransactionAction[] { switch (pushCreditRecord.status) { case PeerPushCreditStatus.DialogProposed: - return [TransactionAction.Delete]; + return [TransactionAction.Retry, TransactionAction.Delete]; case PeerPushCreditStatus.PendingMerge: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushCreditStatus.Done: return [TransactionAction.Delete]; case PeerPushCreditStatus.PendingMergeKycRequired: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushCreditStatus.PendingWithdrawing: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushCreditStatus.SuspendedMerge: return [TransactionAction.Resume, TransactionAction.Abort]; case PeerPushCreditStatus.SuspendedMergeKycRequired: diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -406,7 +406,10 @@ async function handlePurseCreationConflict( const instructedAmount = Amounts.parseOrThrow(peerPushInitiation.amount); const sel = peerPushInitiation.coinSel; - checkDbInvariant(!!sel); + checkDbInvariant( + !!sel, + `no coin selected for peer push initiation ${peerPushInitiation.pursePub}`, + ); const repair: PreviousPayCoins = []; @@ -1218,17 +1221,37 @@ export function computePeerPushDebitTransactionActions( ): TransactionAction[] { switch (ppiRecord.status) { case PeerPushDebitStatus.PendingCreatePurse: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushDebitStatus.PendingReady: - return [TransactionAction.Abort, TransactionAction.Suspend]; + return [ + TransactionAction.Retry, + TransactionAction.Abort, + TransactionAction.Suspend, + ]; case PeerPushDebitStatus.Aborted: return [TransactionAction.Delete]; case PeerPushDebitStatus.AbortingDeletePurse: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.AbortingRefreshDeleted: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.AbortingRefreshExpired: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case PeerPushDebitStatus.SuspendedAbortingRefreshExpired: return [TransactionAction.Resume, TransactionAction.Fail]; case PeerPushDebitStatus.SuspendedAbortingDeletePurse: diff --git a/packages/taler-wallet-core/src/recoup.ts b/packages/taler-wallet-core/src/recoup.ts @@ -199,8 +199,8 @@ async function recoupRefreshCoin( revokedCoin.exchangeBaseUrl, revokedCoin.denomPubHash, ); - checkDbInvariant(!!oldCoinDenom); - checkDbInvariant(!!revokedCoinDenom); + checkDbInvariant(!!oldCoinDenom, `no denom for coin, hash ${oldCoin.denomPubHash}`); + checkDbInvariant(!!revokedCoinDenom, `no revoked denom for coin, hash ${revokedCoin.denomPubHash}`); revokedCoin.status = CoinStatus.Dormant; if (!revokedCoin.spendAllocation) { // We don't know what happened to this coin diff --git a/packages/taler-wallet-core/src/refresh.ts b/packages/taler-wallet-core/src/refresh.ts @@ -29,7 +29,6 @@ import { Amounts, amountToPretty, assertUnreachable, - AsyncFlag, checkDbInvariant, codecForCoinHistoryResponse, codecForExchangeMeltResponse, @@ -68,12 +67,14 @@ import { WalletNotification, } from "@gnu-taler/taler-util"; import { + HttpResponse, readSuccessResponseJsonOrThrow, readTalerErrorResponse, throwUnexpectedRequestError, } from "@gnu-taler/taler-util/http"; import { constructTaskIdentifier, + genericWaitForState, makeCoinsVisible, PendingTaskType, TaskIdStr, @@ -386,7 +387,6 @@ async function getCoinAvailabilityForDenom( denom: DenominationInfo, ageRestriction: number, ): Promise<CoinAvailabilityRecord> { - checkDbInvariant(!!denom); let car = await tx.coinAvailability.get([ denom.exchangeBaseUrl, denom.denomPubHash, @@ -537,7 +537,10 @@ async function destroyRefreshSession( denom, oldCoin.maxAge, ); - checkDbInvariant(car.pendingRefreshOutputCount != null); + checkDbInvariant( + car.pendingRefreshOutputCount != null, + `no pendingRefreshOutputCount for denom ${dph}`, + ); car.pendingRefreshOutputCount = car.pendingRefreshOutputCount - refreshSession.newDenoms[i].count; await tx.coinAvailability.put(car); @@ -693,7 +696,7 @@ async function refreshMelt( switch (resp.status) { case HttpStatusCode.NotFound: { const errDetail = await readTalerErrorResponse(resp); - await handleRefreshMeltNotFound(ctx, coinIndex, errDetail); + await handleRefreshMeltNotFound(ctx, coinIndex, resp, errDetail); return; } case HttpStatusCode.Gone: { @@ -898,9 +901,18 @@ async function handleRefreshMeltConflict( async function handleRefreshMeltNotFound( ctx: RefreshTransactionContext, coinIndex: number, + resp: HttpResponse, errDetails: TalerErrorDetail, ): Promise<void> { - // FIXME: Validate the exchange's error response + // Make sure that we only act on a 404 that indicates a final problem + // with the coin. + switch (errDetails.code) { + case TalerErrorCode.EXCHANGE_GENERIC_COIN_UNKNOWN: + case TalerErrorCode.EXCHANGE_GENERIC_DENOMINATION_KEY_UNKNOWN: + break; + default: + throwUnexpectedRequestError(resp, errDetails); + } await ctx.wex.db.runReadWriteTx( { storeNames: [ @@ -1242,7 +1254,10 @@ async function refreshReveal( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denomInfo); + checkDbInvariant( + !!denomInfo, + `no denom with hash ${coin.denomPubHash}`, + ); const car = await getCoinAvailabilityForDenom( wex, tx, @@ -1252,6 +1267,7 @@ async function refreshReveal( checkDbInvariant( car.pendingRefreshOutputCount != null && car.pendingRefreshOutputCount > 0, + `no pendingRefreshOutputCount for denom ${coin.denomPubHash} age ${coin.maxAge}`, ); car.pendingRefreshOutputCount--; car.freshCoinCount++; @@ -1559,9 +1575,22 @@ async function applyRefreshToOldCoins( coin.denomPubHash, coin.maxAge, ]); - checkDbInvariant(!!coinAv); - checkDbInvariant(coinAv.freshCoinCount > 0); + checkDbInvariant( + !!coinAv, + `no denom info for ${coin.denomPubHash} age ${coin.maxAge}`, + ); + checkDbInvariant( + coinAv.freshCoinCount > 0, + `no fresh coins for ${coin.denomPubHash}`, + ); coinAv.freshCoinCount--; + if (coin.visible) { + if (!coinAv.visibleCoinCount) { + logger.error("coin availability inconsistent"); + } else { + coinAv.visibleCoinCount--; + } + } await tx.coinAvailability.put(coinAv); break; } @@ -1770,7 +1799,7 @@ export async function forceRefresh( ], }, async (tx) => { - let coinPubs: CoinRefreshRequest[] = []; + const coinPubs: CoinRefreshRequest[] = []; for (const c of req.refreshCoinSpecs) { const coin = await tx.coins.get(c.coinPub); if (!coin) { @@ -1782,7 +1811,7 @@ export async function forceRefresh( coin.exchangeBaseUrl, coin.denomPubHash, ); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `no denom hash: ${coin.denomPubHash}`); coinPubs.push({ coinPub: c.coinPub, amount: c.amount ?? denom.value, @@ -1818,66 +1847,38 @@ export async function waitRefreshFinal( const ctx = new RefreshTransactionContext(wex, refreshGroupId); wex.taskScheduler.startShepherdTask(ctx.taskId); - // FIXME: Clean up using the new JS "using" / Symbol.dispose syntax. - const refreshNotifFlag = new AsyncFlag(); - // Raise purchaseNotifFlag whenever we get a notification - // about our refresh. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - refreshNotifFlag.raise(); - } - }); - const unregisterOnCancelled = wex.cancellationToken.onCancelled(() => { - cancelNotif(); - refreshNotifFlag.raise(); + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + // Check if refresh is final + const res = await ctx.wex.db.runReadOnlyTx( + { storeNames: ["refreshGroups"] }, + async (tx) => { + return { + rg: await tx.refreshGroups.get(ctx.refreshGroupId), + }; + }, + ); + const { rg } = res; + if (!rg) { + // Must've been deleted, we consider that final. + return true; + } + switch (rg.operationStatus) { + case RefreshOperationStatus.Failed: + case RefreshOperationStatus.Finished: + // Transaction is final + return true; + case RefreshOperationStatus.Pending: + case RefreshOperationStatus.Suspended: + break; + } + return false; + }, + filterNotification(notif): boolean { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, }); - - try { - await internalWaitRefreshFinal(ctx, refreshNotifFlag); - } catch (e) { - unregisterOnCancelled(); - cancelNotif(); - } -} - -async function internalWaitRefreshFinal( - ctx: RefreshTransactionContext, - flag: AsyncFlag, -): Promise<void> { - while (true) { - if (ctx.wex.cancellationToken.isCancelled) { - throw Error("cancelled"); - } - - // Check if refresh is final - const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["refreshGroups", "operationRetries"] }, - async (tx) => { - return { - rg: await tx.refreshGroups.get(ctx.refreshGroupId), - }; - }, - ); - const { rg } = res; - if (!rg) { - // Must've been deleted, we consider that final. - return; - } - switch (rg.operationStatus) { - case RefreshOperationStatus.Failed: - case RefreshOperationStatus.Finished: - // Transaction is final - return; - case RefreshOperationStatus.Pending: - case RefreshOperationStatus.Suspended: - break; - } - - // Wait for the next transition - await flag.wait(); - flag.reset(); - } } diff --git a/packages/taler-wallet-core/src/shepherd.ts b/packages/taler-wallet-core/src/shepherd.ts @@ -50,12 +50,13 @@ import { parseTaskIdentifier, } from "./common.js"; import { - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, WalletDbAllStoresReadOnlyTransaction, WalletDbReadOnlyTransaction, timestampAbsoluteFromDb, + timestampPreciseToDb, } from "./db.js"; import { computeDepositTransactionStatus, @@ -113,6 +114,8 @@ const logger = new Logger("shepherd.ts"); */ interface ShepherdInfo { cts: CancellationToken.Source; + latch?: Promise<void>; + stopped: boolean; } /** @@ -256,29 +259,36 @@ export class TaskSchedulerImpl implements TaskScheduler { async reload(): Promise<void> { await this.ensureRunning(); const tasksIds = [...this.sheps.keys()]; - logger.info(`reloading sheperd with ${tasksIds.length} tasks`); + logger.info(`reloading shepherd with ${tasksIds.length} tasks`); for (const taskId of tasksIds) { - this.stopShepherdTask(taskId); + await this.stopShepherdTask(taskId); } for (const taskId of tasksIds) { this.startShepherdTask(taskId); } } - private async internalStartShepherdTask(taskId: TaskIdStr): Promise<void> { logger.trace(`Starting to shepherd task ${taskId}`); const oldShep = this.sheps.get(taskId); if (oldShep) { - logger.trace(`Already have a shepherd for ${taskId}`); - return; + if (!oldShep.stopped) { + logger.trace(`Already have a shepherd for ${taskId}`); + return; + } + logger.trace( + `Waiting old task to complete the loop in cancel mode ${taskId}`, + ); + await oldShep.latch; } logger.trace(`Creating new shepherd for ${taskId}`); const newShep: ShepherdInfo = { cts: CancellationToken.create(), + stopped: false, }; this.sheps.set(taskId, newShep); try { - await this.internalShepherdTask(taskId, newShep); + newShep.latch = this.internalShepherdTask(taskId, newShep); + await newShep.latch; } finally { logger.trace(`Done shepherding ${taskId}`); this.sheps.delete(taskId); @@ -291,8 +301,8 @@ export class TaskSchedulerImpl implements TaskScheduler { const oldShep = this.sheps.get(taskId); if (oldShep) { logger.trace(`Cancelling old shepherd for ${taskId}`); - oldShep.cts.cancel(); - this.sheps.delete(taskId); + oldShep.cts.cancel(`stopping task ${taskId}`); + oldShep.stopped = true; this.iterCond.trigger(); } } @@ -306,6 +316,7 @@ export class TaskSchedulerImpl implements TaskScheduler { const maybeNotification = await this.ws.db.runAllStoresReadWriteTx( {}, async (tx) => { + logger.trace(`storing task [reset] for ${taskId}`); await tx.operationRetries.delete(taskId); return taskToRetryNotification(this.ws, tx, taskId, undefined); }, @@ -325,7 +336,13 @@ export class TaskSchedulerImpl implements TaskScheduler { try { await info.cts.token.racePromise(this.ws.timerGroup.resolveAfter(delay)); } catch (e) { - logger.info(`waiting for ${taskId} interrupted`); + if (e instanceof CancellationToken.CancellationError) { + logger.info( + `waiting for ${taskId} interrupted: ${e.message} ${j2s(e.reason)}`, + ); + } else { + logger.info(`waiting for ${taskId} interrupted: ${e}`); + } } } @@ -363,13 +380,14 @@ export class TaskSchedulerImpl implements TaskScheduler { try { res = await callOperationHandlerForTaskId(wex, taskId); } catch (e) { + logger.trace(`Shepherd error ${taskId} saving response ${e}`); res = { type: TaskRunResultType.Error, errorDetail: getErrorDetailFromException(e), }; } if (info.cts.token.isCancelled) { - logger.trace("task cancelled, not processing result"); + logger.trace(`task ${taskId} cancelled, not processing result`); return; } if (this.ws.stopped) { @@ -382,7 +400,9 @@ export class TaskSchedulerImpl implements TaskScheduler { }); switch (res.type) { case TaskRunResultType.Error: { - logger.trace(`Shepherd for ${taskId} got error result.`); + logger.trace( + `Shepherd for ${taskId} got error result: ${j2s(res.errorDetail)}`, + ); const retryRecord = await storePendingTaskError( this.ws, taskId, @@ -412,8 +432,13 @@ export class TaskSchedulerImpl implements TaskScheduler { } case TaskRunResultType.ScheduleLater: { logger.trace(`Shepherd for ${taskId} got schedule-later result.`); - await storeTaskProgress(this.ws, taskId); - const delay = AbsoluteTime.remaining(res.runAt); + const retryRecord = await storePendingTaskPending( + this.ws, + taskId, + res.runAt, + ); + const t = timestampAbsoluteFromDb(retryRecord.retryInfo.nextRetry); + const delay = AbsoluteTime.remaining(t); logger.trace(`Waiting for ${delay.d_ms} ms`); await this.wait(taskId, info, delay); break; @@ -451,7 +476,7 @@ async function storePendingTaskError( pendingTaskId: string, e: TalerErrorDetail, ): Promise<OperationRetryRecord> { - logger.info(`storing pending task error for ${pendingTaskId}`); + logger.trace(`storing task [pending] with ERROR for ${pendingTaskId}`); const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); if (!retryRecord) { @@ -483,6 +508,7 @@ async function storeTaskProgress( ws: InternalWalletState, pendingTaskId: string, ): Promise<void> { + logger.trace(`storing task [progress] for ${pendingTaskId}`); await ws.db.runReadWriteTx( { storeNames: ["operationRetries"] }, async (tx) => { @@ -494,7 +520,9 @@ async function storeTaskProgress( async function storePendingTaskPending( ws: InternalWalletState, pendingTaskId: string, + schedTime?: AbsoluteTime, ): Promise<OperationRetryRecord> { + logger.trace(`storing task [pending] for ${pendingTaskId}`); const res = await ws.db.runAllStoresReadWriteTx({}, async (tx) => { let retryRecord = await tx.operationRetries.get(pendingTaskId); let hadError = false; @@ -510,6 +538,11 @@ async function storePendingTaskPending( delete retryRecord.lastError; retryRecord.retryInfo = DbRetryInfo.increment(retryRecord.retryInfo); } + if (schedTime) { + retryRecord.retryInfo.nextRetry = timestampPreciseToDb( + AbsoluteTime.toPreciseTimestamp(schedTime), + ); + } await tx.operationRetries.put(retryRecord); let notification: WalletNotification | undefined = undefined; if (hadError) { @@ -535,6 +568,7 @@ async function storePendingTaskFinished( ws: InternalWalletState, pendingTaskId: string, ): Promise<void> { + logger.trace(`storing task [finished] for ${pendingTaskId}`); await ws.db.runReadWriteTx( { storeNames: ["operationRetries"] }, async (tx) => { @@ -978,8 +1012,8 @@ export async function getActiveTaskIds( }, async (tx) => { const active = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); // Withdrawals diff --git a/packages/taler-wallet-core/src/transactions.ts b/packages/taler-wallet-core/src/transactions.ts @@ -62,8 +62,8 @@ import { DenomLossEventRecord, DepositElementStatus, DepositGroupRecord, - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, OperationRetryRecord, PeerPullCreditRecord, PeerPullDebitRecordStatus, @@ -93,7 +93,6 @@ import { computeDenomLossTransactionStatus, DenomLossTransactionContext, ExchangeWireDetails, - fetchFreshExchange, getExchangeWireDetailsInTx, } from "./exchanges.js"; import { @@ -244,11 +243,14 @@ export async function getTransactionById( const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); - const exchangeDetails = await getExchangeWireDetailsInTx( - tx, - withdrawalGroupRecord.exchangeBaseUrl, - ); - if (!exchangeDetails) throw Error("not exchange details"); + const exchangeDetails = + withdrawalGroupRecord.exchangeBaseUrl === undefined + ? undefined + : await getExchangeWireDetailsInTx( + tx, + withdrawalGroupRecord.exchangeBaseUrl, + ); + // if (!exchangeDetails) throw Error("not exchange details"); if ( withdrawalGroupRecord.wgInfo.withdrawalType === @@ -260,7 +262,10 @@ export async function getTransactionById( ort, ); } - + checkDbInvariant( + exchangeDetails !== undefined, + "manual withdrawal without exchange", + ); return buildTransactionForManualWithdraw( withdrawalGroupRecord, exchangeDetails, @@ -405,7 +410,10 @@ export async function getTransactionById( const debit = await tx.peerPushDebit.get(parsedTx.pursePub); if (!debit) throw Error("not found"); const ct = await tx.contractTerms.get(debit.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant( + !!ct, + `no contract terms for p2p push ${parsedTx.pursePub}`, + ); return buildTransactionForPushPaymentDebit( debit, ct.contractTermsRaw, @@ -429,7 +437,10 @@ export async function getTransactionById( const pushInc = await tx.peerPushCredit.get(peerPushCreditId); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant( + !!ct, + `no contract terms for p2p push ${peerPushCreditId}`, + ); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; @@ -441,7 +452,7 @@ export async function getTransactionById( } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pushInc); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); return buildTransactionForPeerPushCredit( pushInc, @@ -469,7 +480,7 @@ export async function getTransactionById( const pushInc = await tx.peerPullCredit.get(pursePub); if (!pushInc) throw Error("not found"); const ct = await tx.contractTerms.get(pushInc.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pursePub}`); let wg: WithdrawalGroupRecord | undefined = undefined; let wgOrt: OperationRetryRecord | undefined = undefined; @@ -594,6 +605,7 @@ function buildTransactionForPeerPullCredit( const txState = computePeerPullCreditTransactionState(pullCredit); checkDbInvariant(wsr.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wsr.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wsr.exchangeBaseUrl !== undefined, "wg uninitialized"); return { type: TransactionType.PeerPullCredit, txState, @@ -668,6 +680,7 @@ function buildTransactionForPeerPushCredit( } checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized"); const txState = computePeerPushCreditTransactionState(pushInc); return { @@ -720,16 +733,21 @@ function buildTransactionForPeerPushCredit( function buildTransactionForBankIntegratedWithdraw( wg: WithdrawalGroupRecord, - exchangeDetails: ExchangeWireDetails, + exchangeDetails: ExchangeWireDetails | undefined, ort?: OperationRetryRecord, ): TransactionWithdrawal { if (wg.wgInfo.withdrawalType !== WithdrawalRecordType.BankIntegrated) { throw Error(""); } + const instructedCurrency = + wg.instructedAmount === undefined + ? undefined + : Amounts.currencyOf(wg.instructedAmount); + const currency = wg.wgInfo.bankInfo.currency ?? instructedCurrency; + checkDbInvariant(currency !== undefined, "wg uninitialized (missing currency)"); const txState = computeWithdrawalTransactionStatus(wg); - const zero = Amounts.stringify( - Amounts.zeroOfCurrency(exchangeDetails.currency), - ); + + const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); return { type: TransactionType.Withdrawal, txState, @@ -785,6 +803,7 @@ function buildTransactionForManualWithdraw( checkDbInvariant(wg.instructedAmount !== undefined, "wg uninitialized"); checkDbInvariant(wg.denomsSel !== undefined, "wg uninitialized"); + checkDbInvariant(wg.exchangeBaseUrl !== undefined, "wg uninitialized"); const exchangePaytoUris = augmentPaytoUrisForWithdrawal( plainPaytoUris, wg.reservePub, @@ -1035,8 +1054,14 @@ function buildTransactionForPurchase( })); const timestamp = purchaseRecord.timestampAccept; - checkDbInvariant(!!timestamp); - checkDbInvariant(!!purchaseRecord.payInfo); + checkDbInvariant( + !!timestamp, + `purchase ${purchaseRecord.orderId} without accepted time`, + ); + checkDbInvariant( + !!purchaseRecord.payInfo, + `purchase ${purchaseRecord.orderId} without payinfo`, + ); const txState = computePayMerchantTransactionState(purchaseRecord); return { @@ -1090,6 +1115,10 @@ export async function getWithdrawalTransactionByUri( if (!withdrawalGroupRecord) { return undefined; } + if (withdrawalGroupRecord.exchangeBaseUrl === undefined) { + // prepared and unconfirmed withdrawals are hidden + return undefined; + } const opId = TaskIdentifiers.forWithdrawal(withdrawalGroupRecord); const ort = await tx.operationRetries.get(opId); @@ -1176,7 +1205,7 @@ export async function getTransactions( return; } const ct = await tx.contractTerms.get(pi.contractTermsHash); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPushPaymentDebit(pi, ct.contractTermsRaw), ); @@ -1250,9 +1279,9 @@ export async function getTransactions( } } const pushIncOpId = TaskIdentifiers.forPeerPushCredit(pi); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPeerPushCredit( pi, @@ -1284,9 +1313,9 @@ export async function getTransactions( } } const pushIncOpId = TaskIdentifiers.forPeerPullPaymentInitiation(pi); - let pushIncOrt = await tx.operationRetries.get(pushIncOpId); + const pushIncOrt = await tx.operationRetries.get(pushIncOpId); - checkDbInvariant(!!ct); + checkDbInvariant(!!ct, `no contract terms for p2p push ${pi.pursePub}`); transactions.push( buildTransactionForPeerPullCredit( pi, @@ -1772,7 +1801,11 @@ export async function retryTransaction( } } +/** + * Reset the task retry counter for all tasks. + */ export async function retryAll(wex: WalletExecutionContext): Promise<void> { + await wex.taskScheduler.ensureRunning(); const tasks = wex.taskScheduler.getActiveTasks(); for (const task of tasks) { await wex.taskScheduler.resetTaskRetries(task); @@ -1935,8 +1968,8 @@ async function iterRecordsForWithdrawal( let withdrawalGroupRecords: WithdrawalGroupRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); withdrawalGroupRecords = await tx.withdrawalGroups.indexes.byStatus.getAll(keyRange); @@ -1957,8 +1990,8 @@ async function iterRecordsForDeposit( let dgs: DepositGroupRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); dgs = await tx.depositGroups.indexes.byStatus.getAll(keyRange); } else { @@ -1978,8 +2011,8 @@ async function iterRecordsForDenomLoss( let dgs: DenomLossEventRecord[]; if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); dgs = await tx.denomLossEvents.indexes.byStatus.getAll(keyRange); } else { @@ -1998,8 +2031,8 @@ async function iterRecordsForRefund( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.refundGroups.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2014,8 +2047,8 @@ async function iterRecordsForPurchase( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.purchases.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2030,8 +2063,8 @@ async function iterRecordsForPeerPullCredit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPullCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2046,8 +2079,8 @@ async function iterRecordsForPeerPullDebit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPullDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2062,8 +2095,8 @@ async function iterRecordsForPeerPushDebit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPushDebit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { @@ -2078,8 +2111,8 @@ async function iterRecordsForPeerPushCredit( ): Promise<void> { if (filter.onlyState === "nonfinal") { const keyRange = GlobalIDB.KeyRange.bound( - OPERATION_STATUS_ACTIVE_FIRST, - OPERATION_STATUS_ACTIVE_LAST, + OPERATION_STATUS_NONFINAL_FIRST, + OPERATION_STATUS_NONFINAL_LAST, ); await tx.peerPushCredit.indexes.byStatus.iter(keyRange).forEachAsync(f); } else { diff --git a/packages/taler-wallet-core/src/versions.ts b/packages/taler-wallet-core/src/versions.ts @@ -29,13 +29,6 @@ export const WALLET_EXCHANGE_PROTOCOL_VERSION = "17:0:0"; export const WALLET_MERCHANT_PROTOCOL_VERSION = "5:0:1"; /** - * Protocol version spoken with the bank (bank integration API). - * - * Uses libtool's current:revision:age versioning. - */ -export const WALLET_BANK_INTEGRATION_PROTOCOL_VERSION = "1:0:0"; - -/** * Protocol version spoken with the bank (corebank API). * * Uses libtool's current:revision:age versioning. @@ -52,7 +45,7 @@ export const WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION = "2:0:0"; /** * Libtool version of the wallet-core API. */ -export const WALLET_CORE_API_PROTOCOL_VERSION = "5:0:0"; +export const WALLET_CORE_API_PROTOCOL_VERSION = "7:0:0"; /** * Libtool rules: diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts @@ -123,7 +123,6 @@ import { StartRefundQueryForUriResponse, StartRefundQueryRequest, StoredBackupList, - TalerMerchantApi, TestPayArgs, TestPayResult, TestingGetDenomStatsRequest, @@ -277,6 +276,7 @@ export enum WalletApiOperation { TestingGetDenomStats = "testingGetDenomStats", TestingPing = "testingPing", TestingGetReserveHistory = "testingGetReserveHistory", + TestingResetAllRetries = "testingResetAllRetries", } // group: Initialization @@ -1213,6 +1213,16 @@ export type TestingGetReserveHistoryOp = { }; /** + * Reset all task/transaction retries, + * resulting in immediate re-try of all operations. + */ +export type TestingResetAllRetriesOp = { + op: WalletApiOperation.TestingResetAllRetries; + request: EmptyObject; + response: EmptyObject; +}; + +/** * Get stats about an exchange denomination. */ export type TestingGetDenomStatsOp = { @@ -1356,6 +1366,7 @@ export type WalletOperations = { [WalletApiOperation.ConfirmWithdrawal]: ConfirmWithdrawalOp; [WalletApiOperation.CanonicalizeBaseUrl]: CanonicalizeBaseUrlOp; [WalletApiOperation.TestingGetReserveHistory]: TestingGetReserveHistoryOp; + [WalletApiOperation.TestingResetAllRetries]: TestingResetAllRetriesOp; [WalletApiOperation.HintNetworkAvailability]: HintNetworkAvailabilityOp; }; diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts @@ -56,6 +56,7 @@ import { PrepareWithdrawExchangeResponse, RecoverStoredBackupRequest, StoredBackupList, + TalerBankIntegrationHttpClient, TalerError, TalerErrorCode, TalerProtocolTimestamp, @@ -107,6 +108,7 @@ import { codecForGetExchangeTosRequest, codecForGetWithdrawalDetailsForAmountRequest, codecForGetWithdrawalDetailsForUri, + codecForHintNetworkAvailabilityRequest, codecForImportDbRequest, codecForInitRequest, codecForInitiatePeerPullPaymentRequest, @@ -265,6 +267,7 @@ import { TaskScheduler, TaskSchedulerImpl, convertTaskToTransactionId, + getActiveTaskIds, listTaskForTransactionId, } from "./shepherd.js"; import { @@ -287,12 +290,12 @@ import { getWithdrawalTransactionByUri, parseTransactionIdentifier, resumeTransaction, + retryAll, retryTransaction, suspendTransaction, } from "./transactions.js"; import { WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, WALLET_COREBANK_API_PROTOCOL_VERSION, WALLET_CORE_API_PROTOCOL_VERSION, WALLET_EXCHANGE_PROTOCOL_VERSION, @@ -476,7 +479,10 @@ async function setCoinSuspended( c.denomPubHash, c.maxAge, ]); - checkDbInvariant(!!coinAvailability); + checkDbInvariant( + !!coinAvailability, + `no denom info for ${c.denomPubHash} age ${c.maxAge}`, + ); if (suspended) { if (c.status !== CoinStatus.Fresh) { return; @@ -721,12 +727,11 @@ async function dispatchRequestInternal( case WalletApiOperation.InitWallet: { const req = codecForInitRequest().decode(payload); - logger.info(`init request: ${j2s(req)}`); - - if (wex.ws.initCalled) { - logger.info("initializing wallet (repeat initialization)"); - } else { - logger.info("initializing wallet (first initialization)"); + if (logger.shouldLogTrace()) { + const initType = wex.ws.initCalled + ? "repeat initialization" + : "first initialization"; + logger.trace(`init request (${initType}): ${j2s(req)}`); } // Write to the DB to make sure that we're failing early in @@ -744,7 +749,6 @@ async function dispatchRequestInternal( innerError: getErrorDetailFromException(e), }); } - wex.ws.initWithConfig(applyRunConfigDefaults(req.config)); if (wex.ws.config.testing.skipDefaults) { @@ -757,8 +761,11 @@ async function dispatchRequestInternal( versionInfo: getVersion(wex), }; - // After initialization, task loop should run. - await wex.taskScheduler.ensureRunning(); + if (req.config?.lazyTaskLoop) { + logger.trace("lazily starting task loop"); + } else { + await wex.taskScheduler.ensureRunning(); + } wex.ws.initCalled = true; return resp; @@ -996,6 +1003,7 @@ async function dispatchRequestInternal( talerWithdrawUri: req.talerWithdrawUri, forcedDenomSel: req.forcedDenomSel, restrictAge: req.restrictAge, + amount: req.amount, }); } case WalletApiOperation.ConfirmWithdrawal: { @@ -1005,10 +1013,7 @@ async function dispatchRequestInternal( case WalletApiOperation.PrepareBankIntegratedWithdrawal: { const req = codecForPrepareBankIntegratedWithdrawalRequest().decode(payload); - return prepareBankIntegratedWithdrawal(wex, { - talerWithdrawUri: req.talerWithdrawUri, - selectedExchange: req.selectedExchange, - }); + return prepareBankIntegratedWithdrawal(wex, req); } case WalletApiOperation.GetExchangeTos: { const req = codecForGetExchangeTosRequest().decode(payload); @@ -1046,6 +1051,10 @@ async function dispatchRequestInternal( const req = codecForPrepareWithdrawExchangeRequest().decode(payload); return handlePrepareWithdrawExchange(wex, req); } + case WalletApiOperation.CheckPayForTemplate: { + const req = codecForCheckPayTemplateRequest().decode(payload); + return await checkPayForTemplate(wex, req); + } case WalletApiOperation.PreparePayForUri: { const req = codecForPreparePayRequest().decode(payload); return await preparePayForUri(wex, req.talerPayUri); @@ -1054,10 +1063,6 @@ async function dispatchRequestInternal( const req = codecForPreparePayTemplateRequest().decode(payload); return preparePayForTemplate(wex, req); } - case WalletApiOperation.CheckPayForTemplate: { - const req = codecForCheckPayTemplateRequest().decode(payload); - return checkPayForTemplate(wex, req); - } case WalletApiOperation.ConfirmPay: { const req = codecForConfirmPayRequest().decode(payload); let transactionId; @@ -1085,7 +1090,7 @@ async function dispatchRequestInternal( return {}; } case WalletApiOperation.GetActiveTasks: { - const allTasksId = wex.taskScheduler.getActiveTasks(); + const allTasksId = (await getActiveTaskIds(wex.ws)).taskIds; const tasksInfo = await Promise.all( allTasksId.map(async (id) => { @@ -1234,10 +1239,16 @@ async function dispatchRequestInternal( await loadBackupRecovery(wex, req); return {}; } - // case WalletApiOperation.GetPlanForOperation: { - // const req = codecForGetPlanForOperationRequest().decode(payload); - // return await getPlanForOperation(ws, req); - // } + case WalletApiOperation.HintNetworkAvailability: { + const req = codecForHintNetworkAvailabilityRequest().decode(payload); + if (req.isNetworkAvailable) { + await retryAll(wex); + } else { + // We're not doing anything right now, but we could stop showing + // certain errors! + } + return {}; + } case WalletApiOperation.ConvertDepositAmount: { const req = codecForConvertAmountRequest.decode(payload); return await convertDepositAmount(wex, req); @@ -1388,7 +1399,10 @@ async function dispatchRequestInternal( return; } wex.ws.exchangeCache.clear(); - checkDbInvariant(!!existingRec.id); + checkDbInvariant( + !!existingRec.id, + `no global exchange for ${j2s(key)}`, + ); await tx.globalCurrencyExchanges.delete(existingRec.id); }, ); @@ -1421,6 +1435,9 @@ async function dispatchRequestInternal( await waitTasksDone(wex); return {}; } + case WalletApiOperation.TestingResetAllRetries: + await retryAll(wex); + return {}; case WalletApiOperation.RemoveGlobalCurrencyAuditor: { const req = codecForRemoveGlobalCurrencyAuditorRequest().decode(payload); await wex.db.runReadWriteTx( @@ -1434,7 +1451,10 @@ async function dispatchRequestInternal( if (!existingRec) { return; } - checkDbInvariant(!!existingRec.id); + checkDbInvariant( + !!existingRec.id, + `no global currency for ${j2s(key)}`, + ); await tx.globalCurrencyAuditors.delete(existingRec.id); wex.ws.exchangeCache.clear(); }, @@ -1569,9 +1589,9 @@ export function getVersion(wex: WalletExecutionContext): WalletCoreVersion { exchange: WALLET_EXCHANGE_PROTOCOL_VERSION, merchant: WALLET_MERCHANT_PROTOCOL_VERSION, bankConversionApiRange: WALLET_BANK_CONVERSION_API_PROTOCOL_VERSION, - bankIntegrationApiRange: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bankIntegrationApiRange: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, corebankApiRange: WALLET_COREBANK_API_PROTOCOL_VERSION, - bank: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + bank: TalerBankIntegrationHttpClient.PROTOCOL_VERSION, devMode: wex.ws.config.testing.devModeActive, }; return result; @@ -1629,10 +1649,10 @@ async function handleCoreApiRequest( if (!ws.initCalled) { throw Error("init must be called first"); } - // Might be lazily initialized! - await ws.taskScheduler.ensureRunning(); } + await ws.ensureWalletDbOpen(); + let wex: WalletExecutionContext; let oc: ObservabilityContext; @@ -1832,7 +1852,7 @@ class WalletDbTriggerSpec implements TriggerSpec { if (info.mode !== "readwrite") { return; } - logger.info( + logger.trace( `in after commit callback for readwrite, modified ${j2s([ ...info.modifiedStores, ])}`, @@ -1924,8 +1944,6 @@ export class InternalWalletState { initWithConfig(newConfig: WalletRunConfig): void { this._config = newConfig; - logger.info(`setting new config to ${j2s(newConfig)}`); - this._http = this.httpFactory(newConfig); if (this.config.testing.devModeActive) { diff --git a/packages/taler-wallet-core/src/withdraw.ts b/packages/taler-wallet-core/src/withdraw.ts @@ -44,6 +44,8 @@ import { Duration, EddsaPrivateKeyString, ExchangeBatchWithdrawRequest, + ExchangeListItem, + ExchangeTosStatus, ExchangeUpdateStatus, ExchangeWireAccount, ExchangeWithdrawBatchResponse, @@ -114,8 +116,10 @@ import { TransitionResult, TransitionResultType, constructTaskIdentifier, + genericWaitForState, makeCoinAvailable, makeCoinsVisible, + requireExchangeTosAcceptedOrThrow, } from "./common.js"; import { EddsaKeypair } from "./crypto/cryptoImplementation.js"; import { @@ -149,6 +153,7 @@ import { getExchangePaytoUri, getExchangeWireDetailsInTx, listExchanges, + lookupExchangeByUri, markExchangeUsed, } from "./exchanges.js"; import { DbAccess } from "./query.js"; @@ -159,10 +164,7 @@ import { notifyTransition, parseTransactionIdentifier, } from "./transactions.js"; -import { - WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, - WALLET_EXCHANGE_PROTOCOL_VERSION, -} from "./versions.js"; +import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "./versions.js"; import { WalletExecutionContext, getDenomInfo } from "./wallet.js"; /** @@ -343,9 +345,11 @@ export class WithdrawTransactionContext implements TransactionContext { "exchanges" as const, "exchangeDetails" as const, ]; - let stores = opts.extraStores + const stores = opts.extraStores ? [...baseStores, ...opts.extraStores] : baseStores; + + let errorThrown: Error | undefined; const transitionInfo = await this.wex.db.runReadWriteTx( { storeNames: stores }, async (tx) => { @@ -358,7 +362,17 @@ export class WithdrawTransactionContext implements TransactionContext { major: TransactionMajorState.None, }; } - const res = await f(wgRec, tx); + let res: TransitionResult<WithdrawalGroupRecord> | undefined; + try { + res = await f(wgRec, tx); + } catch (error) { + if (error instanceof Error) { + errorThrown = error; + } + return undefined; + } + + // const res = await f(wgRec, tx); switch (res.type) { case TransitionResultType.Transition: { await tx.withdrawalGroups.put(res.rec); @@ -383,6 +397,9 @@ export class WithdrawTransactionContext implements TransactionContext { } }, ); + if (errorThrown) { + throw errorThrown; + } notifyTransition(this.wex, this.transactionId, transitionInfo); return transitionInfo; } @@ -715,15 +732,35 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.Done: return [TransactionAction.Delete]; case WithdrawalGroupStatus.PendingRegisteringBank: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingReady: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingQueryingStatus: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingWaitConfirmBank: - return [TransactionAction.Suspend, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.AbortingBank: - return [TransactionAction.Suspend, TransactionAction.Fail]; + return [ + TransactionAction.Retry, + TransactionAction.Suspend, + TransactionAction.Fail, + ]; case WithdrawalGroupStatus.SuspendedAbortingBank: return [TransactionAction.Resume, TransactionAction.Fail]; case WithdrawalGroupStatus.SuspendedQueryingStatus: @@ -735,9 +772,17 @@ export function computeWithdrawalTransactionActions( case WithdrawalGroupStatus.SuspendedReady: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.PendingAml: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Resume, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.PendingKyc: - return [TransactionAction.Resume, TransactionAction.Abort]; + return [ + TransactionAction.Retry, + TransactionAction.Resume, + TransactionAction.Abort, + ]; case WithdrawalGroupStatus.SuspendedAml: return [TransactionAction.Resume, TransactionAction.Abort]; case WithdrawalGroupStatus.SuspendedKyc: @@ -842,7 +887,7 @@ export async function getBankWithdrawalInfo( TalerErrorCode.WALLET_BANK_INTEGRATION_PROTOCOL_VERSION_INCOMPATIBLE, { bankProtocolVersion: config.version, - walletProtocolVersion: WALLET_BANK_INTEGRATION_PROTOCOL_VERSION, + walletProtocolVersion: bankApi.PROTOCOL_VERSION, }, "bank integration protocol version not compatible with wallet", ); @@ -857,13 +902,48 @@ export async function getBankWithdrawalInfo( } const { body: status } = resp; + const maxAmount = + status.max_amount === undefined + ? undefined + : Amounts.parseOrThrow(status.max_amount); + + let amount: AmountJson | undefined; + let editableAmount = false; + if (status.amount !== undefined) { + amount = Amounts.parseOrThrow(status.amount); + } else { + amount = + status.suggested_amount === undefined + ? undefined + : Amounts.parseOrThrow(status.suggested_amount); + editableAmount = true; + } + + let wireFee: AmountJson | undefined; + if (status.card_fees) { + wireFee = Amounts.parseOrThrow(status.card_fees); + } + + let exchange: string | undefined = undefined; + let editableExchange = false; + if (status.required_exchange !== undefined) { + exchange = status.required_exchange; + } else { + exchange = status.suggested_exchange; + editableExchange = true; + } return { operationId: uriResult.withdrawalOperationId, apiBaseUrl: uriResult.bankIntegrationApiBaseUrl, - amount: Amounts.parseOrThrow(status.amount), + currency: config.currency, + amount, + wireFee, confirmTransferUrl: status.confirm_transfer_url, senderWire: status.sender_wire, - suggestedExchange: status.suggested_exchange, + exchange, + editableAmount, + editableExchange, + maxAmount, wireTypes: status.wire_types, status: status.status, }; @@ -917,6 +997,10 @@ async function processPlanchetGenerate( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; let planchet = await wex.db.runReadOnlyTx( { storeNames: ["planchets"] }, @@ -958,7 +1042,7 @@ async function processPlanchetGenerate( return getDenomInfo(wex, tx, exchangeBaseUrl, denomPubHash); }, ); - checkDbInvariant(!!denom); + checkDbInvariant(!!denom, `no denom info for ${denomPubHash}`); const r = await wex.cryptoApi.createPlanchet({ denomPub: denom.denomPub, feeWithdraw: Amounts.parseOrThrow(denom.feeWithdraw), @@ -1121,6 +1205,10 @@ async function processPlanchetExchangeBatchRequest( logger.info( `processing planchet exchange batch request ${withdrawalGroup.withdrawalGroupId}, start=${args.coinStartIndex}, len=${args.batchSize}`, ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; const batchReq: ExchangeBatchWithdrawRequest = { planchets: [] }; @@ -1256,6 +1344,10 @@ async function processPlanchetVerifyAndStoreCoin( resp: ExchangeWithdrawResponse, ): Promise<void> { const withdrawalGroup = wgContext.wgRecord; + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; logger.trace(`checking and storing planchet idx=${coinIdx}`); @@ -1505,6 +1597,10 @@ async function processQueryReserve( return TaskRunResult.backoff(); } checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + checkDbInvariant( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); @@ -1740,6 +1836,10 @@ async function redenominateWithdrawal( return; } checkDbInvariant( + wg.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); + checkDbInvariant( wg.denomsSel !== undefined, "can't process uninitialized exchange", ); @@ -1882,7 +1982,12 @@ async function processWithdrawalGroupPendingReady( withdrawalGroup.denomsSel !== undefined, "can't process uninitialized exchange", ); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); const exchangeBaseUrl = withdrawalGroup.exchangeBaseUrl; + logger.trace(`updating exchange beofre processing wg`); await fetchFreshExchange(wex, withdrawalGroup.exchangeBaseUrl); if (withdrawalGroup.denomsSel.selectedDenoms.length === 0) { @@ -2162,14 +2267,6 @@ export async function getExchangeWithdrawalInfo( logger.trace("selection done"); - if (selectedDenoms.selectedDenoms.length === 0) { - throw Error( - `unable to withdraw from ${exchangeBaseUrl}, can't select denominations for instructed amount (${Amounts.stringify( - instructedAmount, - )}`, - ); - } - const exchangeWireAccounts: string[] = []; for (const account of exchange.wireInfo.accounts) { @@ -2248,38 +2345,52 @@ export async function getWithdrawalDetailsForUri( logger.trace(`getting withdrawal details for URI ${talerWithdrawUri}`); const info = await getBankWithdrawalInfo(wex.http, talerWithdrawUri); logger.trace(`got bank info`); - if (info.suggestedExchange) { + if (info.exchange) { try { // If the exchange entry doesn't exist yet, // it'll be created as an ephemeral entry. - await fetchFreshExchange(wex, info.suggestedExchange); + await fetchFreshExchange(wex, info.exchange); } catch (e) { // We still continued if it failed, as other exchanges might be available. // We don't want to fail if the bank-suggested exchange is broken/offline. logger.trace( - `querying bank-suggested exchange (${info.suggestedExchange}) failed`, + `querying bank-suggested exchange (${info.exchange}) failed`, ); } } - const currency = Amounts.currencyOf(info.amount); + const currency = info.currency; - const listExchangesResp = await listExchanges(wex); - const possibleExchanges = listExchangesResp.exchanges.filter((x) => { - return ( - x.currency === currency && - (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || - x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) - ); - }); + let possibleExchanges: ExchangeListItem[]; + if (!info.editableExchange && info.exchange !== undefined) { + const ex: ExchangeListItem = await lookupExchangeByUri(wex, { + exchangeBaseUrl: info.exchange, + }); + possibleExchanges = [ex]; + } else { + const listExchangesResp = await listExchanges(wex); + + possibleExchanges = listExchangesResp.exchanges.filter((x) => { + return ( + x.currency === currency && + (x.exchangeUpdateStatus === ExchangeUpdateStatus.Ready || + x.exchangeUpdateStatus === ExchangeUpdateStatus.ReadyUpdate) + ); + }); + } return { operationId: info.operationId, confirmTransferUrl: info.confirmTransferUrl, status: info.status, - amount: Amounts.stringify(info.amount), - defaultExchangeBaseUrl: info.suggestedExchange, + currency, + editableAmount: info.editableAmount, + editableExchange: info.editableExchange, + maxAmount: info.maxAmount ? Amounts.stringify(info.maxAmount) : undefined, + amount: info.amount ? Amounts.stringify(info.amount) : undefined, + defaultExchangeBaseUrl: info.exchange, possibleExchanges, + wireFee: info.wireFee ? Amounts.stringify(info.wireFee) : undefined, }; } @@ -2306,7 +2417,11 @@ export async function getFundingPaytoUris( withdrawalGroupId: string, ): Promise<string[]> { const withdrawalGroup = await tx.withdrawalGroups.get(withdrawalGroupId); - checkDbInvariant(!!withdrawalGroup); + checkDbInvariant(!!withdrawalGroup, `no withdrawal for ${withdrawalGroupId}`); + checkDbInvariant( + withdrawalGroup.exchangeBaseUrl !== undefined, + "can't get funding uri from uninitialized wg", + ); checkDbInvariant( withdrawalGroup.instructedAmount !== undefined, "can't get funding uri from uninitialized wg", @@ -2379,6 +2494,7 @@ export function getBankAbortUrl(talerWithdrawUri: string): string { async function registerReserveWithBank( wex: WalletExecutionContext, withdrawalGroupId: string, + isFlexibleAmount: boolean, ): Promise<void> { const withdrawalGroup = await wex.db.runReadOnlyTx( { storeNames: ["withdrawalGroups"] }, @@ -2407,7 +2523,11 @@ async function registerReserveWithBank( const reqBody = { reserve_pub: withdrawalGroup.reservePub, selected_exchange: bankInfo.exchangePaytoUri, - }; + } as any; + if (isFlexibleAmount) { + reqBody.amount = withdrawalGroup.instructedAmount; + } + logger.trace(`isFlexibleAmount: ${isFlexibleAmount}`); logger.info(`registering reserve with bank: ${j2s(reqBody)}`); const httpResp = await wex.http.fetch(bankStatusUrl, { method: "POST", @@ -2516,7 +2636,9 @@ async function processBankRegisterReserve( // FIXME: Put confirm transfer URL in the DB! - await registerReserveWithBank(wex, withdrawalGroupId); + const isFlexibleAmount = status.amount == null; + + await registerReserveWithBank(wex, withdrawalGroupId, isFlexibleAmount); return TaskRunResult.progress(); } @@ -2553,6 +2675,7 @@ async function processReserveBankStatus( uriResult.bankIntegrationApiBaseUrl, ); bankStatusUrl.searchParams.set("long_poll_ms", "30000"); + bankStatusUrl.searchParams.set("old_state", "selected"); logger.info(`long-polling for withdrawal operation at ${bankStatusUrl.href}`); const statusResp = await wex.http.fetch(bankStatusUrl.href, { @@ -2655,7 +2778,7 @@ export async function internalPrepareCreateWithdrawalGroup( args: { reserveStatus: WithdrawalGroupStatus; amount?: AmountJson; - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; reserveKeyPair?: EddsaKeypair; @@ -2696,7 +2819,7 @@ export async function internalPrepareCreateWithdrawalGroup( let initialDenomSel: DenomSelectionState | undefined; const denomSelUid = encodeCrock(getRandomBytes(16)); - if (amount !== undefined) { + if (amount !== undefined && exchangeBaseUrl !== undefined) { initialDenomSel = await getInitialDenomsSelection( wex, exchangeBaseUrl, @@ -2727,7 +2850,9 @@ export async function internalPrepareCreateWithdrawalGroup( wgInfo: args.wgInfo, }; - await fetchFreshExchange(wex, exchangeBaseUrl); + if (exchangeBaseUrl !== undefined) { + await fetchFreshExchange(wex, exchangeBaseUrl); + } const transactionId = constructTransactionIdentifier({ tag: TransactionType.Withdrawal, @@ -2737,12 +2862,13 @@ export async function internalPrepareCreateWithdrawalGroup( return { withdrawalGroup, transactionId, - creationInfo: !amount - ? undefined - : { - amount, - canonExchange: exchangeBaseUrl, - }, + creationInfo: + !amount || !exchangeBaseUrl + ? undefined + : { + amount, + canonExchange: exchangeBaseUrl, + }, }; } @@ -2772,8 +2898,8 @@ export async function internalPerformCreateWithdrawalGroup( if (existingWg) { return { withdrawalGroup: existingWg, - exchangeNotif: undefined, transitionInfo: undefined, + exchangeNotif: undefined, }; } await tx.withdrawalGroups.add(withdrawalGroup); @@ -2789,7 +2915,21 @@ export async function internalPerformCreateWithdrawalGroup( exchangeNotif: undefined, }; } - const exchange = await tx.exchanges.get(prep.creationInfo.canonExchange); + return internalPerformExchangeWasUsed( + wex, + tx, + prep.creationInfo.canonExchange, + withdrawalGroup, + ); +} + +export async function internalPerformExchangeWasUsed( + wex: WalletExecutionContext, + tx: WalletDbReadWriteTransaction<["exchanges"]>, + canonExchange: string, + withdrawalGroup: WithdrawalGroupRecord, +): Promise<PerformCreateWithdrawalGroupResult> { + const exchange = await tx.exchanges.get(canonExchange); if (exchange) { exchange.lastWithdrawal = timestampPreciseToDb(TalerPreciseTimestamp.now()); await tx.exchanges.put(exchange); @@ -2805,11 +2945,7 @@ export async function internalPerformCreateWithdrawalGroup( newTxState, }; - const exchangeUsedRes = await markExchangeUsed( - wex, - tx, - prep.creationInfo.canonExchange, - ); + const exchangeUsedRes = await markExchangeUsed(wex, tx, canonExchange); const ctx = new WithdrawTransactionContext( wex, @@ -2837,7 +2973,7 @@ export async function internalCreateWithdrawalGroup( wex: WalletExecutionContext, args: { reserveStatus: WithdrawalGroupStatus; - exchangeBaseUrl: string; + exchangeBaseUrl: string | undefined; amount?: AmountJson; forcedWithdrawalGroupId?: string; forcedDenomSel?: ForcedDenomSel; @@ -2883,7 +3019,6 @@ export async function prepareBankIntegratedWithdrawal( wex: WalletExecutionContext, req: { talerWithdrawUri: string; - selectedExchange?: string; }, ): Promise<PrepareBankIntegratedWithdrawalResponse> { const existingWithdrawalGroup = await wex.db.runReadOnlyTx( @@ -2912,12 +3047,6 @@ export async function prepareBankIntegratedWithdrawal( const info = await getWithdrawalDetailsForUri(wex, req.talerWithdrawUri); - const exchangeBaseUrl = - req.selectedExchange ?? withdrawInfo.suggestedExchange; - if (!exchangeBaseUrl) { - return { info }; - } - /** * Withdrawal group without exchange and amount * this is an special case when the user haven't yet @@ -2926,7 +3055,7 @@ export async function prepareBankIntegratedWithdrawal( * same URI */ const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - exchangeBaseUrl, + exchangeBaseUrl: undefined, wgInfo: { withdrawalType: WithdrawalRecordType.BankIntegrated, bankInfo: { @@ -2935,6 +3064,7 @@ export async function prepareBankIntegratedWithdrawal( timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, wireTypes: withdrawInfo.wireTypes, + currency: withdrawInfo.currency, }, }, reserveStatus: WithdrawalGroupStatus.DialogProposed, @@ -2957,6 +3087,9 @@ export async function confirmWithdrawal( req: ConfirmWithdrawalRequest, ): Promise<void> { const parsedTx = parseTransactionIdentifier(req.transactionId); + const selectedExchange = req.exchangeBaseUrl; + const instructedAmount = Amounts.parseOrThrow(req.amount); + if (parsedTx?.tag !== TransactionType.Withdrawal) { throw Error("invalid withdrawal transaction ID"); } @@ -2978,38 +3111,44 @@ export async function confirmWithdrawal( throw Error("not a bank integrated withdrawal"); } - const selectedExchange = req.exchangeBaseUrl; const exchange = await fetchFreshExchange(wex, selectedExchange); + requireExchangeTosAcceptedOrThrow(exchange); const talerWithdrawUri = withdrawalGroup.wgInfo.bankInfo.talerWithdrawUri; const confirmUrl = withdrawalGroup.wgInfo.bankInfo.confirmUrl; /** - * The only reasong this to be undefined is because it is an old wallet - * database before adding the wireType field was added + * The only reason this could be undefined is because it is an old wallet + * database before adding the prepareWithdrawal feature */ - let wtypes: string[]; - if (withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined) { + let bankWireTypes: string[]; + let bankCurrency: string; + if ( + withdrawalGroup.wgInfo.bankInfo.wireTypes === undefined || + withdrawalGroup.wgInfo.bankInfo.currency === undefined + ) { const withdrawInfo = await getBankWithdrawalInfo( wex.http, talerWithdrawUri, ); - wtypes = withdrawInfo.wireTypes; + bankWireTypes = withdrawInfo.wireTypes; + bankCurrency = withdrawInfo.currency; } else { - wtypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + bankWireTypes = withdrawalGroup.wgInfo.bankInfo.wireTypes; + bankCurrency = withdrawalGroup.wgInfo.bankInfo.currency; } const exchangePaytoUri = await getExchangePaytoUri( wex, selectedExchange, - wtypes, + bankWireTypes, ); const withdrawalAccountList = await fetchWithdrawalAccountInfo( wex, { exchange, - instructedAmount: Amounts.parseOrThrow(req.amount), + instructedAmount, }, wex.cancellationToken, ); @@ -3020,23 +3159,34 @@ export async function confirmWithdrawal( ); const initalDenoms = await getInitialDenomsSelection( wex, - req.exchangeBaseUrl, - Amounts.parseOrThrow(req.amount), + exchange.exchangeBaseUrl, + instructedAmount, req.forcedDenomSel, ); - ctx.transition({}, async (rec) => { + let pending = false; + await ctx.transition({}, async (rec) => { if (!rec) { return TransitionResult.stay(); } switch (rec.status) { + case WithdrawalGroupStatus.PendingWaitConfirmBank: { + pending = true; + return TransitionResult.stay(); + } + case WithdrawalGroupStatus.AbortedOtherWallet: { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + } case WithdrawalGroupStatus.DialogProposed: { - rec.exchangeBaseUrl = req.exchangeBaseUrl; + rec.exchangeBaseUrl = exchange.exchangeBaseUrl; rec.instructedAmount = req.amount; + rec.restrictAge = req.restrictAge; rec.denomsSel = initalDenoms; rec.rawWithdrawalAmount = initalDenoms.totalWithdrawCost; rec.effectiveWithdrawalAmount = initalDenoms.totalCoinValue; - rec.restrictAge = req.restrictAge; rec.wgInfo = { withdrawalType: WithdrawalRecordType.BankIntegrated, @@ -3047,20 +3197,50 @@ export async function confirmWithdrawal( confirmUrl: confirmUrl, timestampBankConfirmed: undefined, timestampReserveInfoPosted: undefined, - wireTypes: wtypes, + wireTypes: bankWireTypes, + currency: bankCurrency, }, }; - + pending = true; rec.status = WithdrawalGroupStatus.PendingRegisteringBank; return TransitionResult.transition(rec); } - default: - throw Error("unable to confirm withdrawal in current state"); + default: { + throw Error( + `unable to confirm withdrawal in current state: ${rec.status}`, + ); + } } }); await wex.taskScheduler.resetTaskRetries(ctx.taskId); - wex.taskScheduler.startShepherdTask(ctx.taskId); + + wex.ws.notify({ + type: NotificationType.BalanceChange, + hintTransactionId: ctx.transactionId, + }); + + const res = await wex.db.runReadWriteTx( + { + storeNames: ["exchanges"], + }, + async (tx) => { + const r = await internalPerformExchangeWasUsed( + wex, + tx, + exchange.exchangeBaseUrl, + withdrawalGroup, + ); + return r; + }, + ); + if (res.exchangeNotif) { + wex.ws.notify(res.exchangeNotif); + } + + if (pending) { + await waitWithdrawalRegistered(wex, ctx); + } } /** @@ -3080,181 +3260,119 @@ export async function acceptWithdrawalFromUri( selectedExchange: string; forcedDenomSel?: ForcedDenomSel; restrictAge?: number; + amount?: AmountLike; }, ): Promise<AcceptWithdrawalResponse> { const selectedExchange = req.selectedExchange; logger.info( - `accepting withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, - ); - const existingWithdrawalGroup = await wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups"] }, - async (tx) => { - return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( - req.talerWithdrawUri, - ); - }, + `preparing withdrawal via ${req.talerWithdrawUri}, canonicalized selected exchange ${selectedExchange}`, ); - if (existingWithdrawalGroup) { - let url: string | undefined; - if ( - existingWithdrawalGroup.wgInfo.withdrawalType === - WithdrawalRecordType.BankIntegrated - ) { - url = existingWithdrawalGroup.wgInfo.bankInfo.confirmUrl; + const p = await prepareBankIntegratedWithdrawal(wex, { + talerWithdrawUri: req.talerWithdrawUri, + }); + + let amount: AmountString; + if (p.info.amount == null) { + if (req.amount == null) { + throw Error( + "amount required, as withdrawal operation has flexible amount", + ); } - return { - reservePub: existingWithdrawalGroup.reservePub, - confirmTransferUrl: url, - transactionId: constructTransactionIdentifier({ - tag: TransactionType.Withdrawal, - withdrawalGroupId: existingWithdrawalGroup.withdrawalGroupId, - }), - }; + amount = req.amount as AmountString; + } else { + if (req.amount != null && Amounts.cmp(req.amount, p.info.amount) != 0) { + throw Error( + "mismatched amount, amount is fixed by bank but client provided different amount", + ); + } + amount = p.info.amount; } - const exchange = await fetchFreshExchange(wex, selectedExchange); - const withdrawInfo = await getBankWithdrawalInfo( - wex.http, - req.talerWithdrawUri, - ); - const exchangePaytoUri = await getExchangePaytoUri( - wex, - selectedExchange, - withdrawInfo.wireTypes, - ); - - const withdrawalAccountList = await fetchWithdrawalAccountInfo( - wex, - { - exchange, - instructedAmount: withdrawInfo.amount, - }, - CancellationToken.CONTINUE, - ); - - const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - amount: withdrawInfo.amount, - exchangeBaseUrl: req.selectedExchange, - wgInfo: { - withdrawalType: WithdrawalRecordType.BankIntegrated, - exchangeCreditAccounts: withdrawalAccountList, - bankInfo: { - exchangePaytoUri, - talerWithdrawUri: req.talerWithdrawUri, - confirmUrl: withdrawInfo.confirmTransferUrl, - timestampBankConfirmed: undefined, - timestampReserveInfoPosted: undefined, - wireTypes: withdrawInfo.wireTypes, - }, - }, + logger.info(`confirming withdrawal with tx ${p.transactionId}`); + await confirmWithdrawal(wex, { + amount: Amounts.stringify(amount), + exchangeBaseUrl: selectedExchange, + transactionId: p.transactionId, restrictAge: req.restrictAge, forcedDenomSel: req.forcedDenomSel, - reserveStatus: WithdrawalGroupStatus.PendingRegisteringBank, }); - const withdrawalGroupId = withdrawalGroup.withdrawalGroupId; - - const ctx = new WithdrawTransactionContext(wex, withdrawalGroupId); - - wex.ws.notify({ - type: NotificationType.BalanceChange, - hintTransactionId: ctx.transactionId, - }); - - await waitWithdrawalRegistered(wex, ctx); + const newWithdrawralGroup = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups"] }, + async (tx) => { + return await tx.withdrawalGroups.indexes.byTalerWithdrawUri.get( + req.talerWithdrawUri, + ); + }, + ); - wex.taskScheduler.startShepherdTask(ctx.taskId); + checkDbInvariant( + newWithdrawralGroup !== undefined, + "withdrawal don't exist after confirm", + ); return { - reservePub: withdrawalGroup.reservePub, - confirmTransferUrl: withdrawInfo.confirmTransferUrl, - transactionId: ctx.transactionId, + reservePub: newWithdrawralGroup.reservePub, + confirmTransferUrl: p.info.confirmTransferUrl, + transactionId: p.transactionId, }; } -async function internalWaitWithdrawalRegistered( +async function waitWithdrawalRegistered( wex: WalletExecutionContext, ctx: WithdrawTransactionContext, - withdrawalNotifFlag: AsyncFlag, ): Promise<void> { - while (true) { - const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups", "operationRetries"] }, - async (tx) => { - return { - withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), - retryRec: await tx.operationRetries.get(ctx.taskId), - }; - }, - ); + await genericWaitForState(wex, { + async checkState(): Promise<boolean> { + const { withdrawalRec, retryRec } = await wex.db.runReadOnlyTx( + { storeNames: ["withdrawalGroups", "operationRetries"] }, + async (tx) => { + return { + withdrawalRec: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), + retryRec: await tx.operationRetries.get(ctx.taskId), + }; + }, + ); - if (!withdrawalRec) { - throw Error("withdrawal not found anymore"); - } + if (!withdrawalRec) { + throw Error("withdrawal not found anymore"); + } - switch (withdrawalRec.status) { - case WithdrawalGroupStatus.FailedBankAborted: - throw TalerError.fromDetail( - TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, - {}, - ); - case WithdrawalGroupStatus.PendingKyc: - case WithdrawalGroupStatus.PendingAml: - case WithdrawalGroupStatus.PendingQueryingStatus: - case WithdrawalGroupStatus.PendingReady: - case WithdrawalGroupStatus.Done: - case WithdrawalGroupStatus.PendingWaitConfirmBank: - return; - case WithdrawalGroupStatus.PendingRegisteringBank: - break; - default: { - if (retryRec) { - if (retryRec.lastError) { - throw TalerError.fromUncheckedDetail(retryRec.lastError); - } else { - throw Error("withdrawal unexpectedly pending"); + switch (withdrawalRec.status) { + case WithdrawalGroupStatus.FailedBankAborted: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_WITHDRAWAL_OPERATION_ABORTED_BY_BANK, + {}, + ); + case WithdrawalGroupStatus.PendingKyc: + case WithdrawalGroupStatus.PendingAml: + case WithdrawalGroupStatus.PendingQueryingStatus: + case WithdrawalGroupStatus.PendingReady: + case WithdrawalGroupStatus.Done: + case WithdrawalGroupStatus.PendingWaitConfirmBank: + return true; + case WithdrawalGroupStatus.PendingRegisteringBank: + break; + default: { + if (retryRec) { + if (retryRec.lastError) { + throw TalerError.fromUncheckedDetail(retryRec.lastError); + } else { + throw Error("withdrawal unexpectedly pending"); + } } } } - } - - await withdrawalNotifFlag.wait(); - withdrawalNotifFlag.reset(); - } -} - -async function waitWithdrawalRegistered( - wex: WalletExecutionContext, - ctx: WithdrawTransactionContext, -): Promise<void> { - // FIXME: Doesn't support cancellation yet - // FIXME: We should use Symbol.dispose magic here for cleanup! - - const withdrawalNotifFlag = new AsyncFlag(); - // Raise exchangeNotifFlag whenever we get a notification - // about our exchange. - const cancelNotif = wex.ws.addNotificationListener((notif) => { - if ( - notif.type === NotificationType.TransactionStateTransition && - notif.transactionId === ctx.transactionId - ) { - logger.info(`raising update notification: ${j2s(notif)}`); - withdrawalNotifFlag.raise(); - } + return false; + }, + filterNotification(notif) { + return ( + notif.type === NotificationType.TransactionStateTransition && + notif.transactionId === ctx.transactionId + ); + }, }); - - try { - const res = await internalWaitWithdrawalRegistered( - wex, - ctx, - withdrawalNotifFlag, - ); - logger.info("done waiting for ready exchange"); - return res; - } finally { - cancelNotif(); - } } async function fetchAccount( @@ -3422,7 +3540,7 @@ export async function createManualWithdrawal( ); const withdrawalGroup = await internalCreateWithdrawalGroup(wex, { - amount: Amounts.jsonifyAmount(req.amount), + amount: amount, wgInfo: { withdrawalType: WithdrawalRecordType.BankManual, exchangeCreditAccounts: withdrawalAccountsList, @@ -3507,7 +3625,7 @@ async function internalWaitWithdrawalFinal( // Check if refresh is final const res = await ctx.wex.db.runReadOnlyTx( - { storeNames: ["withdrawalGroups", "operationRetries"] }, + { storeNames: ["withdrawalGroups"] }, async (tx) => { return { wg: await tx.withdrawalGroups.get(ctx.withdrawalGroupId), @@ -3550,7 +3668,7 @@ export async function getWithdrawalDetailsForAmount( type: ObservabilityEventType.Message, contents: `Cancelling previous key ${clientCancelKey}`, }); - prevCts.cancel(); + prevCts.cancel(`getting details amount`); } else { wex.oc.observe({ type: ObservabilityEventType.Message, diff --git a/packages/taler-wallet-embedded/package.json b/packages/taler-wallet-embedded/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-embedded", - "version": "0.10.7", + "version": "0.11.4", "description": "", "engines": { "node": ">=0.18.0" diff --git a/packages/taler-wallet-embedded/src/wallet-qjs-tests.ts b/packages/taler-wallet-embedded/src/wallet-qjs-tests.ts @@ -0,0 +1,118 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + (C) 2024 Taler Systems SA + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { userIdentifierDerive } from "@gnu-taler/anastasis-core/lib/crypto.js"; +import { AmountString, j2s } from "@gnu-taler/taler-util"; +import { + WalletApiOperation, + createNativeWalletHost2, +} from "@gnu-taler/taler-wallet-core"; + +export async function testWithGv() { + const w = await createNativeWalletHost2({}); + await w.wallet.client.call(WalletApiOperation.InitWallet, { + config: { + features: { + allowHttp: true, + }, + }, + }); + await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { + amountToSpend: "KUDOS:1" as AmountString, + amountToWithdraw: "KUDOS:3" as AmountString, + corebankApiBaseUrl: "https://bank.demo.taler.net/", + exchangeBaseUrl: "https://exchange.demo.taler.net/", + merchantBaseUrl: "https://backend.demo.taler.net/", + merchantAuthToken: "secret-token:sandbox", + }); + await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); + await w.wallet.client.call(WalletApiOperation.Shutdown, {}); +} + +export async function testWithFdold() { + const w = await createNativeWalletHost2({}); + await w.wallet.client.call(WalletApiOperation.InitWallet, { + config: { + features: { + allowHttp: true, + }, + }, + }); + await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { + amountToSpend: "TESTKUDOS:1" as AmountString, + amountToWithdraw: "TESTKUDOS:3" as AmountString, + corebankApiBaseUrl: "https://bank.taler.fdold.eu/", + exchangeBaseUrl: "https://exchange.taler.fdold.eu/", + merchantBaseUrl: "https://merchant.taler.fdold.eu/", + }); + await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); + await w.wallet.client.call(WalletApiOperation.Shutdown, {}); +} + +export async function testWithLocal(path: string) { + console.log("running local test"); + const w = await createNativeWalletHost2({ + persistentStoragePath: path ?? "walletdb.json", + }); + console.log("created wallet"); + await w.wallet.client.call(WalletApiOperation.InitWallet, { + config: { + features: { + allowHttp: true, + }, + testing: { + skipDefaults: true, + }, + }, + }); + console.log("initialized wallet"); + await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { + amountToSpend: "TESTKUDOS:1" as AmountString, + amountToWithdraw: "TESTKUDOS:3" as AmountString, + corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/", + exchangeBaseUrl: "http://localhost:8081/", + merchantBaseUrl: "http://localhost:8083/", + }); + console.log("started integration test"); + await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); + console.log("done with task loop"); + await w.wallet.client.call(WalletApiOperation.Shutdown, {}); + console.log("DB stats:", j2s(w.getDbStats())); +} + +export async function testArgon2id() { + const userIdVector = { + input_id_data: { + name: "Fleabag", + ssn: "AB123", + }, + input_server_salt: "FZ48EFS7WS3R2ZR4V53A3GFFY4", + output_id: + "YS45R6CGJV84K1NN7T14ZBCPVTZ6H15XJSM1FV0R748MHPV82SM0126EBZKBAAGCR34Q9AFKPEW1HRT2Q9GQ5JRA3642AB571DKZS18", + }; + + if ( + (await userIdentifierDerive( + userIdVector.input_id_data, + userIdVector.input_server_salt, + )) != userIdVector.output_id + ) { + throw Error("argon2id is not working!"); + } + + console.log("argon2id is working!"); +} diff --git a/packages/taler-wallet-embedded/src/wallet-qjs.ts b/packages/taler-wallet-embedded/src/wallet-qjs.ts @@ -1,6 +1,7 @@ /* This file is part of GNU Taler (C) 2019 GNUnet e.V. + (C) 2024 Taler Systems SA GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software @@ -28,30 +29,27 @@ import { mergeDiscoveryAggregate, reduceAction, } from "@gnu-taler/anastasis-core"; -import { userIdentifierDerive } from "@gnu-taler/anastasis-core/lib/crypto.js"; import { - AmountString, CoreApiMessageEnvelope, CoreApiResponse, CoreApiResponseSuccess, Logger, - PartialWalletRunConfig, WalletNotification, enableNativeLogging, getErrorDetailFromException, - j2s, openPromise, performanceNow, setGlobalLogLevelFromString, } from "@gnu-taler/taler-util"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { qjsOs } from "@gnu-taler/taler-util/qtart"; +import { Wallet, createNativeWalletHost2 } from "@gnu-taler/taler-wallet-core"; import { - DefaultNodeWalletArgs, - Wallet, - WalletApiOperation, - createNativeWalletHost2, -} from "@gnu-taler/taler-wallet-core"; + testArgon2id, + testWithFdold, + testWithGv, + testWithLocal, +} from './wallet-qjs-tests.js'; setGlobalLogLevelFromString("trace"); @@ -68,9 +66,6 @@ function sendNativeMessage(ev: CoreApiMessageEnvelope): void { } class NativeWalletMessageHandler { - walletArgs: DefaultNodeWalletArgs | undefined; - walletConfig: PartialWalletRunConfig | undefined; - maybeWallet: Wallet | undefined; wp = openPromise<Wallet>(); httpLib = createPlatformHttpLib(); @@ -91,23 +86,9 @@ class NativeWalletMessageHandler { }; }; - let initResponse: any = {}; - - const reinit = async () => { - logger.info("in reinit"); - const wR = await createNativeWalletHost2(this.walletArgs); - const w = wR.wallet; - this.maybeWallet = w; - const resp = await w.handleCoreApiRequest("initWallet", "native-init", { - config: this.walletConfig, - }); - initResponse = resp.type == "response" ? resp.result : resp.error; - this.wp.resolve(w); - }; - switch (operation) { case "init": { - this.walletArgs = { + const wR = await createNativeWalletHost2({ notifyHandler: async (notification: WalletNotification) => { sendNativeMessage({ type: "notification", payload: notification }); }, @@ -115,38 +96,29 @@ class NativeWalletMessageHandler { httpLib: this.httpLib, cryptoWorkerType: args.cryptoWorkerType, ...args, - }; - this.walletConfig = args.config ?? {}; - const logLevel = args.logLevel; - if (logLevel) { - setGlobalLogLevelFromString(logLevel); + }); + + if (args.logLevel) { + setGlobalLogLevelFromString(args.logLevel); } - const nativeLogging = args.useNativeLogging ?? false; - if (nativeLogging) { + + if (args.useNativeLogging === true) { enableNativeLogging(); } - await reinit(); + + const resp = await wR.wallet.handleCoreApiRequest("initWallet", "native-init", { + config: args.config ?? {}, + }); + + let initResponse: any = resp.type == "response" ? resp.result : resp.error; + + this.wp.resolve(wR.wallet); + return wrapSuccessResponse({ ...initResponse, }); } - case "startTunnel": { - // this.httpLib.useNfcTunnel = true; - throw Error("not implemented"); - } - case "stopTunnel": { - // this.httpLib.useNfcTunnel = false; - throw Error("not implemented"); - } - case "tunnelResponse": { - // httpLib.handleTunnelResponse(msg.args); - throw Error("not implemented"); - } - case "reset": { - throw Error( - "reset not supported anymore, please use the clearDb wallet-core request", - ); - } + default: { const wallet = await this.wp.promise; return await wallet.handleCoreApiRequest(operation, id, args); @@ -175,17 +147,22 @@ async function handleAnastasisRequest( let req = args ?? {}; switch (operation) { - case "anastasisReduce": - // TODO: do some input validation here + case "anastasisReduce": { let reduceRes = await reduceAction(req.state, req.action, req.args ?? {}); // For now, this will return "success" even if the wrapped Anastasis // response is a ReducerStateError. return wrapSuccessResponse(reduceRes); - case "anastasisStartBackup": + } + + case "anastasisStartBackup": { return wrapSuccessResponse(await getBackupStartState()); - case "anastasisStartRecovery": + } + + case "anastasisStartRecovery": { return wrapSuccessResponse(await getRecoveryStartState()); - case "anastasisDiscoverPolicies": + } + + case "anastasisDiscoverPolicies": { let discoverRes = await discoverPolicies(req.state, req.cursor); let aggregatedPolicies = mergeDiscoveryAggregate( discoverRes.policies ?? [], @@ -199,19 +176,25 @@ async function handleAnastasisRequest( cursor: discoverRes.cursor, }, }); - default: + } + + default: { throw Error("unsupported anastasis operation"); + } } } export function installNativeWalletListener(): void { setGlobalLogLevelFromString("trace"); + const handler = new NativeWalletMessageHandler(); + const onMessage = async (msgStr: any): Promise<void> => { if (typeof msgStr !== "string") { logger.error("expected string as message"); return; } + const msg = JSON.parse(msgStr); const operation = msg.operation; if (typeof operation !== "string") { @@ -220,20 +203,23 @@ export function installNativeWalletListener(): void { ); return; } + const id = msg.id; logger.info(`native listener: got request for ${operation} (${id})`); - const startTimeNs = performanceNow(); - + const startTimeMs = performanceNow(); let respMsg: CoreApiResponse; + try { if (msg.operation.startsWith("anastasis")) { + // Entry point for Anastasis respMsg = await handleAnastasisRequest(operation, id, msg.args ?? {}); } else if (msg.operation === "testing-dangerously-eval") { // Eval code, used only for testing. No client may rely on this. logger.info(`evaluating ${msg.args.jscode}`); const f = new Function(msg.args.jscode); f(); + respMsg = { type: "response", result: {}, @@ -241,6 +227,7 @@ export function installNativeWalletListener(): void { id: msg.id, }; } else { + // Entry point for wallet-core respMsg = await handler.handleMessage(operation, id, msg.args ?? {}); } } catch (e) { @@ -251,10 +238,12 @@ export function installNativeWalletListener(): void { error: getErrorDetailFromException(e), }; } - const endTimeNs = performanceNow(); + + const endTimeMs = performanceNow(); const requestDurationMs = Math.round( - Number((endTimeNs - startTimeNs) / 1000n / 1000n), + Number((endTimeMs - startTimeMs) / 1000n / 1000n), ); + logger.info( `native listener: sending back ${respMsg.type} message for operation ${operation} (${id}) after ${requestDurationMs} ms`, ); @@ -268,102 +257,6 @@ export function installNativeWalletListener(): void { // @ts-ignore globalThis.installNativeWalletListener = installNativeWalletListener; - -export async function testWithGv() { - const w = await createNativeWalletHost2({}); - await w.wallet.client.call(WalletApiOperation.InitWallet, { - config: { - features: { - allowHttp: true, - }, - }, - }); - await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { - amountToSpend: "KUDOS:1" as AmountString, - amountToWithdraw: "KUDOS:3" as AmountString, - corebankApiBaseUrl: "https://bank.demo.taler.net/", - exchangeBaseUrl: "https://exchange.demo.taler.net/", - merchantBaseUrl: "https://backend.demo.taler.net/", - merchantAuthToken: "secret-token:sandbox", - }); - await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); - await w.wallet.client.call(WalletApiOperation.Shutdown, {}); -} - -export async function testWithFdold() { - const w = await createNativeWalletHost2({}); - await w.wallet.client.call(WalletApiOperation.InitWallet, { - config: { - features: { - allowHttp: true, - }, - }, - }); - await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { - amountToSpend: "TESTKUDOS:1" as AmountString, - amountToWithdraw: "TESTKUDOS:3" as AmountString, - corebankApiBaseUrl: "https://bank.taler.fdold.eu/", - exchangeBaseUrl: "https://exchange.taler.fdold.eu/", - merchantBaseUrl: "https://merchant.taler.fdold.eu/", - }); - await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); - await w.wallet.client.call(WalletApiOperation.Shutdown, {}); -} - -export async function testWithLocal(path: string) { - console.log("running local test"); - const w = await createNativeWalletHost2({ - persistentStoragePath: path ?? "walletdb.json", - }); - console.log("created wallet"); - await w.wallet.client.call(WalletApiOperation.InitWallet, { - config: { - features: { - allowHttp: true, - }, - testing: { - skipDefaults: true, - }, - }, - }); - console.log("initialized wallet"); - await w.wallet.client.call(WalletApiOperation.RunIntegrationTest, { - amountToSpend: "TESTKUDOS:1" as AmountString, - amountToWithdraw: "TESTKUDOS:3" as AmountString, - corebankApiBaseUrl: "http://localhost:8082/taler-bank-access/", - exchangeBaseUrl: "http://localhost:8081/", - merchantBaseUrl: "http://localhost:8083/", - }); - console.log("started integration test"); - await w.wallet.client.call(WalletApiOperation.TestingWaitTasksDone, {}); - console.log("done with task loop"); - await w.wallet.client.call(WalletApiOperation.Shutdown, {}); - console.log("DB stats:", j2s(w.getDbStats())); -} - -export async function testArgon2id() { - const userIdVector = { - input_id_data: { - name: "Fleabag", - ssn: "AB123", - }, - input_server_salt: "FZ48EFS7WS3R2ZR4V53A3GFFY4", - output_id: - "YS45R6CGJV84K1NN7T14ZBCPVTZ6H15XJSM1FV0R748MHPV82SM0126EBZKBAAGCR34Q9AFKPEW1HRT2Q9GQ5JRA3642AB571DKZS18", - }; - - if ( - (await userIdentifierDerive( - userIdVector.input_id_data, - userIdVector.input_server_salt, - )) != userIdVector.output_id - ) { - throw Error("argon2id is not working!"); - } - - console.log("argon2id is working!"); -} - // @ts-ignore globalThis.testWithGv = testWithGv; // @ts-ignore diff --git a/packages/taler-wallet-webextension/manifest-common.json b/packages/taler-wallet-webextension/manifest-common.json @@ -2,7 +2,7 @@ "name": "GNU Taler Wallet (git)", "description": "Privacy preserving and transparent payments", "author": "GNU Taler Developers", - "version": "0.10.7", + "version": "0.11.4", "icons": { "16": "static/img/taler-logo-16.png", "19": "static/img/taler-logo-19.png", @@ -14,5 +14,5 @@ "256": "static/img/taler-logo-256.png", "512": "static/img/taler-logo-512.png" }, - "version_name": "0.10.7" + "version_name": "0.11.4" } diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/taler-wallet-webextension", - "version": "0.10.7", + "version": "0.11.4", "description": "GNU Taler Wallet browser extension", "main": "./build/index.js", "types": "./build/index.d.ts", diff --git a/packages/taler-wallet-webextension/src/components/HistoryItem.tsx b/packages/taler-wallet-webextension/src/components/HistoryItem.tsx @@ -26,7 +26,7 @@ import { DenomLossEventType, parsePaytoUri, } from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Avatar } from "../mui/Avatar.js"; import { Pages } from "../NavigationBar.js"; @@ -49,6 +49,8 @@ export function HistoryItem(props: { tx: Transaction }): VNode { */ switch (tx.type) { case TransactionType.Withdrawal: + //withdrawal that has not been confirmed are hidden + if (!tx.exchangeBaseUrl) return <Fragment /> return ( <Layout id={tx.transactionId} diff --git a/packages/taler-wallet-webextension/src/components/WalletActivity.tsx b/packages/taler-wallet-webextension/src/components/WalletActivity.tsx @@ -22,7 +22,7 @@ import { TalerErrorDetail, TaskProgressNotification, WalletNotification, - assertUnreachable + assertUnreachable, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -83,7 +83,9 @@ export function WalletActivity(): VNode { cursor: "pointer", }} > - click here to open + <i18n.Translate> + Click here to open the wallet activity tab. + </i18n.Translate> </div> </div> ); diff --git a/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx b/packages/taler-wallet-webextension/src/cta/InvoicePay/views.tsx @@ -24,12 +24,21 @@ import { InvoicePaymentDetails, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; +import { AbsoluteTime, Duration } from "@gnu-taler/taler-util"; export function ReadyView( state: State.Ready | State.NoBalanceForCurrency | State.NoEnoughBalance, ): VNode { const { i18n } = useTranslationContext(); const { summary, effective, raw, expiration, uri, status, payStatus } = state; + + const inFiveMinutes = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 5 }), + ); + const willExpireSoon = + expiration && AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; + return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -42,11 +51,13 @@ export function ReadyView( /> } /> - <Part - title={i18n.str`Valid until`} - text={<Time timestamp={expiration} format="dd MMMM yyyy, HH:mm" />} - kind="neutral" - /> + {willExpireSoon && ( + <Part + title={i18n.str`Expires at`} + text={<Time timestamp={expiration} format="HH:mm" />} + kind="neutral" + /> + )} </section> <PaymentButtons amount={effective} diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -18,6 +18,7 @@ import { AbsoluteTime, Amounts, MerchantContractTerms as ContractTerms, + Duration, PreparePayResultType, TranslatedString, } from "@gnu-taler/taler-util"; @@ -54,6 +55,17 @@ export function BaseView(state: SupportedStates): VNode { : Amounts.zeroOfCurrency(state.amount.currency) : state.amount; + const expiration = !contractTerms.pay_deadline + ? undefined + : AbsoluteTime.fromProtocolTimestamp(contractTerms.pay_deadline); + const inFiveMinutes = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 5 }), + ); + const willExpireSoon = + !expiration || expiration.t_ms === "never" + ? undefined + : AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; return ( <Fragment> <ShowImportantMessage state={state} /> @@ -65,7 +77,12 @@ export function BaseView(state: SupportedStates): VNode { <Fragment> <i18n.Translate>Purchase</i18n.Translate> &nbsp; - <AgeSign size={20} title={i18n.str`This purchase is age restricted.`}>{contractTerms.minimum_age}+</AgeSign> + <AgeSign + size={20} + title={i18n.str`This purchase is age restricted.`} + > + {contractTerms.minimum_age}+ + </AgeSign> </Fragment> ) : ( <i18n.Translate>Purchase</i18n.Translate> @@ -79,17 +96,10 @@ export function BaseView(state: SupportedStates): VNode { text={<MerchantDetails merchant={contractTerms.merchant} />} kind="neutral" /> - {contractTerms.pay_deadline && ( + {willExpireSoon && ( <Part - title={i18n.str`Valid until`} - text={ - <Time - timestamp={AbsoluteTime.fromProtocolTimestamp( - contractTerms.pay_deadline, - )} - format="dd MMMM yyyy, HH:mm" - /> - } + title={i18n.str`Expires at`} + text={<Time timestamp={expiration} format="HH:mm" />} kind="neutral" /> )} diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/state.ts @@ -47,12 +47,18 @@ export function useComponentState({ const hook = useAsyncAsHook(async () => { if (!talerTemplateUri) throw Error("ERROR_NO-URI-FOR-PAYMENT-TEMPLATE"); const templateP = await api.wallet.call( - WalletApiOperation.CheckPayForTemplate, { talerPayTemplateUri: talerTemplateUri }, + WalletApiOperation.CheckPayForTemplate, + { talerPayTemplateUri: talerTemplateUri }, ); - const requireMoreInfo = !templateP.templateDetails.template_contract.amount || !templateP.templateDetails.template_contract.summary; + const requireMoreInfo = + !templateP.templateDetails.template_contract.amount || + !templateP.templateDetails.template_contract.summary; let payStatus: PreparePayResult | undefined = undefined; if (!requireMoreInfo) { - payStatus = await api.wallet.call(WalletApiOperation.PreparePayForTemplate, { talerPayTemplateUri: talerTemplateUri }); + payStatus = await api.wallet.call( + WalletApiOperation.PreparePayForTemplate, + { talerPayTemplateUri: talerTemplateUri }, + ); } const balance = await api.wallet.call(WalletApiOperation.GetBalances, {}); return { payStatus, balance, uri: talerTemplateUri, templateP }; @@ -102,20 +108,28 @@ export function useComponentState({ const cfg = hook.response.templateP.templateDetails.template_contract; const def = hook.response.templateP.templateDetails.editable_defaults; - const fixedAmount = cfg.amount !== undefined ? Amounts.parseOrThrow(cfg.amount) : undefined; - const fixedSummary = cfg.summary !== undefined ? cfg.summary : undefined; - - const defaultAmount = def?.amount !== undefined ? Amounts.parseOrThrow(def.amount) : undefined; - const defaultSummary = def?.summary !== undefined ? def.summary : undefined; - - const zero = fixedAmount ? Amounts.zeroOfAmount(fixedAmount) : - cfg.currency !== undefined ? Amounts.zeroOfCurrency(cfg.currency) : - defaultAmount !== undefined ? Amounts.zeroOfAmount(defaultAmount) : - def?.currency !== undefined ? Amounts.zeroOfCurrency(def.currency) : - Amounts.zeroOfCurrency(hook.response.templateP.supportedCurrencies[0]); - - const [amount, setAmount] = useState(defaultAmount ?? zero); - const [summary, setSummary] = useState(defaultSummary ?? ""); + const fixedAmount = + cfg.amount !== undefined ? Amounts.parseOrThrow(cfg.amount) : undefined; + const fixedSummary = cfg.summary; + + const defaultAmount = + def?.amount !== undefined ? Amounts.parseOrThrow(def.amount) : undefined; + const defaultSummary = def?.summary; + + const zero = fixedAmount + ? Amounts.zeroOfAmount(fixedAmount) + : cfg.currency !== undefined + ? Amounts.zeroOfCurrency(cfg.currency) + : defaultAmount !== undefined + ? Amounts.zeroOfAmount(defaultAmount) + : def?.currency !== undefined + ? Amounts.zeroOfCurrency(def.currency) + : Amounts.zeroOfCurrency( + hook.response.templateP.supportedCurrencies[0], + ); + + const [amount, setAmount] = useState(defaultAmount ?? fixedAmount ?? zero); + const [summary, setSummary] = useState(defaultSummary ?? fixedSummary ?? ""); async function createOrder() { try { @@ -140,41 +154,50 @@ export function useComponentState({ } const errors = undefinedIfEmpty({ - amount: fixedAmount !== undefined ? undefined : amount && Amounts.isZero(amount) ? i18n.str`required` : undefined, - summary: fixedSummary !== undefined ? undefined : summary !== undefined && !summary ? i18n.str`required` : undefined, + amount: + fixedAmount !== undefined + ? undefined + : amount && Amounts.isZero(amount) + ? i18n.str`required` + : undefined, + summary: + fixedSummary !== undefined + ? undefined + : summary !== undefined && !summary + ? i18n.str`required` + : undefined, }); return { status: "fill-template", error: undefined, minAge: cfg.minimum_age ?? 0, - amount: - fixedAmount === undefined - ? ({ - onInput: (a) => { - setAmount(a); - }, - value: amount, - error: errors?.amount, - } as AmountFieldHandler) - : undefined, - summary: - fixedSummary === undefined - ? ({ - onInput: (t) => { - setSummary(t); - }, - value: summary, - error: errors?.summary, - } as TextFieldHandler) - : undefined, + amount: { + onInput: + fixedAmount !== undefined + ? undefined + : (a) => { + setAmount(a); + }, + value: amount, + error: errors?.amount, + } as AmountFieldHandler, + summary: { + onInput: + fixedSummary !== undefined + ? undefined + : (t) => { + setSummary(t); + }, + value: summary, + error: errors?.summary, + } as TextFieldHandler, onCreate: { onClick: errors ? undefined : safely("create order for pay template", createOrder), }, }; - } - + }; } function undefinedIfEmpty<T extends object>(obj: T): T | undefined { diff --git a/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx b/packages/taler-wallet-webextension/src/cta/PaymentTemplate/views.tsx @@ -33,24 +33,11 @@ export function ReadyView({ return ( <Fragment> <section style={{ textAlign: "left" }}> - {/* <Part - title={ - <div - style={{ - display: "flex", - alignItems: "center", - }} - > - <i18n.Translate>Merchant</i18n.Translate> - </div> - } - text={<ExchangeDetails exchange={exchangeUrl} />} - kind="neutral" - big - /> */} {!amount ? undefined : ( <p> - <AmountField label={i18n.str`Amount`} handler={amount} /> + <AmountField label={i18n.str`Amount`} + handler={amount} + /> </p> )} {!summary ? undefined : ( @@ -60,6 +47,7 @@ export function ReadyView({ variant="filled" required fullWidth + disabled={summary.onInput === undefined} error={summary.error} value={summary.value} onChange={summary.onInput} @@ -67,12 +55,12 @@ export function ReadyView({ </p> )} </section> - {minAge && ( + {minAge ? ( <section> <AgeSign size={25}>{minAge}+</AgeSign> <i18n.Translate>This purchase is age restricted.</i18n.Translate> </section> - )} + ) : undefined} <section> <Button onClick={onCreate.onClick} variant="contained" color="success"> <i18n.Translate>Review order</i18n.Translate> diff --git a/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx b/packages/taler-wallet-webextension/src/cta/TransferPickup/views.tsx @@ -26,6 +26,7 @@ import { } from "../../wallet/Transaction.js"; import { State } from "./index.js"; import { TermsOfService } from "../../components/TermsOfService/index.js"; +import { AbsoluteTime, Duration } from "@gnu-taler/taler-util"; export function ReadyView({ accept, @@ -36,6 +37,12 @@ export function ReadyView({ raw, }: State.Ready): VNode { const { i18n } = useTranslationContext(); + const inFiveMinutes = AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 5 }), + ); + const willExpireSoon = + expiration && AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -49,15 +56,16 @@ export function ReadyView({ /> } /> - - <Part - title={i18n.str`Valid until`} - text={<Time timestamp={expiration} format="dd MMMM yyyy, HH:mm" />} - kind="neutral" - /> + {willExpireSoon && ( + <Part + title={i18n.str`Expires at`} + text={<Time timestamp={expiration} format="HH:mm" />} + kind="neutral" + /> + )} </section> <section> - <TermsOfService key="terms" exchangeUrl={exchangeBaseUrl} > + <TermsOfService key="terms" exchangeUrl={exchangeBaseUrl}> <Button variant="contained" color="success" onClick={accept.onClick}> <i18n.Translate> Receive &nbsp; {<Amount value={effective} />} diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/index.ts @@ -18,7 +18,7 @@ import { AmountJson, AmountString, CurrencySpecification, - ExchangeListItem + ExchangeListItem, } from "@gnu-taler/taler-util"; import { Loading } from "../../components/Loading.js"; import { State as SelectExchangeState } from "../../hooks/useSelectedExchange.js"; @@ -85,7 +85,7 @@ export namespace State { operationState: "confirmed" | "aborted" | "selected"; thisWallet: boolean; redirectToTx: () => void; - confirmTransferUrl?: string, + confirmTransferUrl?: string; error: undefined; } @@ -95,20 +95,26 @@ export namespace State { currentExchange: ExchangeListItem; - chosenAmount: AmountJson; - withdrawalFee: AmountJson; + amount: AmountFieldHandler; + editableAmount: boolean; + + bankFee: AmountJson; toBeReceived: AmountJson; + toBeSent: AmountJson; doWithdrawal: ButtonHandler; doSelectExchange: ButtonHandler; + editableExchange: boolean; chooseCurrencies: string[]; selectedCurrency: string; changeCurrency: (s: string) => void; - conversionInfo: { - spec: CurrencySpecification, - amount: AmountJson, - } | undefined; + conversionInfo: + | { + spec: CurrencySpecification; + amount: AmountJson; + } + | undefined; ageRestriction?: SelectFieldHandler; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/state.ts @@ -54,7 +54,7 @@ export function useComponentStateFromParams({ ? parseWithdrawExchangeUri(maybeTalerUri) : undefined; const exchangeByTalerUri = updatedExchangeByUser ?? uri?.exchangeBaseUrl; - + let ex: ExchangeFullDetails | undefined; if (exchangeByTalerUri) { await api.wallet.call(WalletApiOperation.AddExchange, { @@ -185,9 +185,16 @@ export function useComponentStateFromParams({ cancel, onSuccess, undefined, - chosenAmount, - exchangeList, - exchangeByTalerUri, + { + amount: chosenAmount, + currency: chosenAmount.currency, + maxAmount: Amounts.zeroOfCurrency(chosenAmount.currency), + bankFee: Amounts.zeroOfCurrency(chosenAmount.currency), + editableAmount: true, + editableExchange: true, + exchange: exchangeByTalerUri, + exchangeList: exchangeList, + }, setUpdatedExchangeByUser, ); } @@ -212,33 +219,21 @@ export function useComponentStateFromURI({ const uriInfo = await api.wallet.call( WalletApiOperation.PrepareBankIntegratedWithdrawal, + { talerWithdrawUri }, + ); + const { status } = uriInfo.info; + const txInfo = await api.wallet.call( + WalletApiOperation.GetTransactionById, { - talerWithdrawUri, - selectedExchange: updatedExchangeByUser, + transactionId: uriInfo.transactionId, }, ); - const { - amount, - defaultExchangeBaseUrl, - possibleExchanges, - confirmTransferUrl, - status, - } = uriInfo.info; - const txInfo = - uriInfo.transactionId === undefined - ? undefined - : await api.wallet.call(WalletApiOperation.GetTransactionById, { - transactionId: uriInfo.transactionId, - }); return { talerWithdrawUri, status, transactionId: uriInfo.transactionId, + bankWithdrawalInfo: uriInfo.info, txInfo: txInfo, - confirmTransferUrl, - amount: Amounts.parseOrThrow(amount), - thisExchange: defaultExchangeBaseUrl, - exchanges: possibleExchanges, }; }); @@ -278,9 +273,22 @@ export function useComponentStateFromURI({ const uri = uriInfoHook.response.talerWithdrawUri; const txId = uriInfoHook.response.transactionId; - const chosenAmount = uriInfoHook.response.amount; - const defaultExchange = uriInfoHook.response.thisExchange; - const exchangeList = uriInfoHook.response.exchanges; + const bwi = uriInfoHook.response.bankWithdrawalInfo; + + const amount = + bwi.amount === undefined + ? Amounts.zeroOfCurrency(bwi.currency) + : Amounts.parseOrThrow(bwi.amount); + + const maxAmount = + bwi.maxAmount === undefined + ? Amounts.zeroOfCurrency(bwi.currency) + : Amounts.parseOrThrow(bwi.maxAmount); + + const bankFee = + bwi.wireFee === undefined + ? Amounts.zeroOfCurrency(bwi.currency) + : Amounts.parseOrThrow(bwi.wireFee); async function doManagedWithdraw( exchange: string, @@ -290,9 +298,6 @@ export function useComponentStateFromURI({ transactionId: string; confirmTransferUrl: string | undefined; }> { - if (!txId) { - throw Error("can't confirm transaction"); - } const res = await api.wallet.call(WalletApiOperation.ConfirmWithdrawal, { exchangeBaseUrl: exchange, amount, @@ -305,12 +310,15 @@ export function useComponentStateFromURI({ }; } - if (uriInfoHook.response.txInfo && uriInfoHook.response.status !== "pending") { + if ( + uriInfoHook.response.txInfo && + uriInfoHook.response.status !== "pending" + ) { const info = uriInfoHook.response.txInfo; return { status: "already-completed", operationState: uriInfoHook.response.status, - confirmTransferUrl: uriInfoHook.response.confirmTransferUrl, + confirmTransferUrl: bwi.confirmTransferUrl, thisWallet: info.txState.major === TransactionMajorState.Pending, redirectToTx: () => onSuccess(info.transactionId), error: undefined, @@ -323,14 +331,32 @@ export function useComponentStateFromURI({ cancel, onSuccess, uri, - chosenAmount, - exchangeList, - defaultExchange, + { + amount, + bankFee, + maxAmount, + currency: bwi.currency, + editableAmount: bwi.editableAmount, + editableExchange: bwi.editableExchange, + exchange: bwi.defaultExchangeBaseUrl, + exchangeList: bwi.possibleExchanges, + }, setUpdatedExchangeByUser, ); }, []); } +type WithdrawalInfo = { + currency: string; + amount: AmountJson; + bankFee: AmountJson; + maxAmount: AmountJson; + editableAmount: boolean; + exchange: string | undefined; + editableExchange: boolean; + exchangeList: ExchangeListItem[]; +}; + type ManualOrManagedWithdrawFunction = ( exchange: string, ageRestricted: number | undefined, @@ -342,16 +368,14 @@ function exchangeSelectionState( cancel: () => Promise<void>, onSuccess: (txid: string) => Promise<void>, talerWithdrawUri: string | undefined, - chosenAmount: AmountJson, - exchangeList: ExchangeListItem[], - exchangeSuggestedByTheBank: string | undefined, + wInfo: WithdrawalInfo, onExchangeUpdated: (ex: string) => void, ): RecursiveState<State> { const api = useBackendContext(); const selectedExchange = useSelectedExchange({ - currency: chosenAmount.currency, - defaultExchange: exchangeSuggestedByTheBank, - list: exchangeList, + currency: wInfo.currency, + defaultExchange: wInfo.exchange, + list: wInfo.exchangeList, }); const current = @@ -364,6 +388,10 @@ function exchangeSelectionState( } }, [current]); + const safeAmount = wInfo.amount + ? wInfo.amount + : Amounts.zeroOfCurrency(wInfo.currency); + if (selectedExchange.status !== "ready") { return selectedExchange; } @@ -374,11 +402,12 @@ function exchangeSelectionState( | State.Loading => { const { i18n } = useTranslationContext(); const { pushAlertOnError } = useAlertContext(); + + const [choosenAmount, setChoosenAmount] = useState(safeAmount); const [ageRestricted, setAgeRestricted] = useState(0); - const currentExchange = selectedExchange.selected; const [selectedCurrency, setSelectedCurrency] = useState<string>( - chosenAmount.currency, + wInfo.currency, ); /** * With the exchange and amount, ask the wallet the information @@ -388,8 +417,8 @@ function exchangeSelectionState( const info = await api.wallet.call( WalletApiOperation.GetWithdrawalDetailsForAmount, { - exchangeBaseUrl: currentExchange.exchangeBaseUrl, - amount: Amounts.stringify(chosenAmount), + exchangeBaseUrl: selectedExchange.selected.exchangeBaseUrl, + amount: Amounts.stringify(choosenAmount), restrictAge: ageRestricted, }, ); @@ -401,20 +430,40 @@ function exchangeSelectionState( return { amount: withdrawAmount, + currentExchange: selectedExchange.selected, ageRestrictionOptions: info.ageRestrictionOptions, accounts: info.withdrawalAccountsList, }; - }, []); + }, [choosenAmount, selectedExchange.selected, ageRestricted]); const [doingWithdraw, setDoingWithdraw] = useState<boolean>(false); + if (!amountHook) { + return { status: "loading", error: undefined }; + } + if (amountHook.hasError) { + return { + status: "error", + error: alertFromError( + i18n, + i18n.str`Could not load the withdrawal details`, + amountHook, + ), + }; + } + if (!amountHook.response) { + return { status: "loading", error: undefined }; + } + + const currentExchange = amountHook.response.currentExchange; + async function doWithdrawAndCheckError(): Promise<void> { try { setDoingWithdraw(true); const res = await doWithdraw( currentExchange.exchangeBaseUrl, !ageRestricted ? undefined : ageRestricted, - Amounts.stringify(chosenAmount), + Amounts.stringify(choosenAmount), ); if (res.confirmTransferUrl) { document.location.href = res.confirmTransferUrl; @@ -429,32 +478,14 @@ function exchangeSelectionState( setDoingWithdraw(false); } - if (!amountHook) { - return { status: "loading", error: undefined }; - } - if (amountHook.hasError) { - return { - status: "error", - error: alertFromError( - i18n, - i18n.str`Could not load the withdrawal details`, - amountHook, - ), - }; - } - if (!amountHook.response) { - return { status: "loading", error: undefined }; - } - - const withdrawalFee = Amounts.sub( - amountHook.response.amount.raw, - amountHook.response.amount.effective, - ).amount; + const toBeSent = amountHook.response.amount.raw; const toBeReceived = amountHook.response.amount.effective; + const bankFee = wInfo.bankFee; + const ageRestrictionOptions = amountHook.response.ageRestrictionOptions?.reduce( - (p, c) => ({ ...p, [c]: `under ${c}` }), + (p, c) => ({ ...p, [c]: i18n.str`under ${c}` }), {} as Record<string, string>, ); @@ -495,28 +526,50 @@ function exchangeSelectionState( amount: Amounts.parseOrThrow(convAccount.transferAmount!), }; + const amountError = Amounts.isZero(choosenAmount) + ? i18n.str`should be greater than zero` + : Amounts.cmp(choosenAmount, wInfo.maxAmount) === -1 + ? i18n.str`choose a lower value` + : undefined; + return { status: "success", error: undefined, - doSelectExchange: selectedExchange.doSelect, + doSelectExchange: { + onClick: wInfo.editableExchange + ? selectedExchange.doSelect.onClick + : undefined, + }, + editableAmount: wInfo.editableAmount, + editableExchange: wInfo.editableExchange, currentExchange, toBeReceived, + toBeSent, chooseCurrencies, + bankFee, selectedCurrency, changeCurrency: (s) => { setSelectedCurrency(s); }, conversionInfo, - withdrawalFee, - chosenAmount, + amount: { + value: choosenAmount, + onInput: wInfo.editableAmount + ? pushAlertOnError(async (v) => { + setChoosenAmount(v); + }) + : undefined, + error: amountError, + }, talerWithdrawUri, ageRestriction, doWithdrawal: { - onClick: doingWithdraw - ? undefined - : pushAlertOnError(doWithdrawAndCheckError), + onClick: + doingWithdraw || amountError + ? undefined + : pushAlertOnError(doWithdrawAndCheckError), }, cancel, }; - }, []); + }, [selectedExchange.selected]); } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/stories.tsx @@ -43,17 +43,25 @@ const ageRestrictionSelectField = { export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 10000000, value: 1, @@ -70,34 +78,41 @@ export const TermsOfServiceNotYetLoaded = tests.createExample(SuccessView, { export const AlreadyAborted = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "aborted" + operationState: "aborted", }); export const AlreadySelected = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "selected" + operationState: "selected", }); export const AlreadyConfirmed = tests.createExample(FinalStateOperation, { error: undefined, status: "already-completed", - operationState: "confirmed" + operationState: "confirmed", }); - export const WithSomeFee = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 10000000, value: 1, @@ -114,17 +129,25 @@ export const WithSomeFee = tests.createExample(SuccessView, { export const WithoutFee = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 0, + }, + }, + bankFee: { + currency: "EUR", fraction: 0, + value: 1, }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -141,17 +164,25 @@ export const WithoutFee = tests.createExample(SuccessView, { export const EditExchangeUntouched = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -168,17 +199,25 @@ export const EditExchangeUntouched = tests.createExample(SuccessView, { export const EditExchangeModified = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -196,18 +235,26 @@ export const WithAgeRestriction = tests.createExample(SuccessView, { error: undefined, status: "success", ageRestriction: ageRestrictionSelectField, - chosenAmount: { - currency: "USD", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "USD", + value: 2, + fraction: 10000000, + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + doSelectExchange: {}, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.demo.taler.net", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "USD", fraction: 0, value: 0, @@ -223,11 +270,19 @@ export const WithAgeRestriction = tests.createExample(SuccessView, { export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "NETZBON", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "NETZBON", + value: 2, + fraction: 10000000, + }, }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, + chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "NETZBON", doWithdrawal: { onClick: nullFunction }, @@ -235,7 +290,7 @@ export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, @@ -251,30 +306,38 @@ export const WithAlternateCurrenciesNETZBON = tests.createExample(SuccessView, { export const WithAlternateCurrenciesEURO = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "NETZBON", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "NETZBON", + value: 2, + fraction: 10000000, + }, + }, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, }, + chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "EUR", - changeCurrency: () => { }, + changeCurrency: () => {}, conversionInfo: { spec: { - name: "EUR" + name: "EUR", } as CurrencySpecification, amount: { currency: "EUR", fraction: 10000000, value: 1, - } + }, }, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, @@ -290,30 +353,37 @@ export const WithAlternateCurrenciesEURO = tests.createExample(SuccessView, { export const WithAlternateCurrenciesEURO11 = tests.createExample(SuccessView, { error: undefined, status: "success", - chosenAmount: { - currency: "NETZBON", - value: 2, - fraction: 10000000, + amount: { + value: { + currency: "NETZBON", + value: 2, + fraction: 10000000, + }, }, chooseCurrencies: ["NETZBON", "EUR"], selectedCurrency: "EUR", - changeCurrency: () => { }, + changeCurrency: () => {}, + bankFee: { + currency: "EUR", + fraction: 0, + value: 1, + }, conversionInfo: { spec: { - name: "EUR" + name: "EUR", } as CurrencySpecification, amount: { currency: "EUR", fraction: 10000000, value: 2, - } + }, }, doWithdrawal: { onClick: nullFunction }, currentExchange: { exchangeBaseUrl: "https://exchange.netzbon.ch", tos: {}, } as Partial<ExchangeListItem> as any, - withdrawalFee: { + toBeSent: { currency: "NETZBON", fraction: 10000000, value: 1, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts b/packages/taler-wallet-webextension/src/cta/Withdraw/test.ts @@ -26,6 +26,7 @@ import { ExchangeListItem, ExchangeTosStatus, ScopeType, + TransactionIdStr, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { expect } from "chai"; @@ -111,13 +112,18 @@ describe("Withdraw CTA states", () => { WalletApiOperation.PrepareBankIntegratedWithdrawal, undefined, { - transactionId: "123", + transactionId: "123" as TransactionIdStr, info: { status: "pending", operationId: "123", + currency: "ARS", amount: "EUR:2" as AmountString, possibleExchanges: [], - } + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", + }, }, ); @@ -152,14 +158,19 @@ describe("Withdraw CTA states", () => { WalletApiOperation.PrepareBankIntegratedWithdrawal, undefined, { - transactionId: "123", + transactionId: "123" as TransactionIdStr, info: { status: "pending", operationId: "123", + currency: "ARS", amount: "ARS:2" as AmountString, possibleExchanges: exchanges, defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl, - } + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", + }, }, ); handler.addWalletCallResponse( @@ -173,7 +184,7 @@ describe("Withdraw CTA states", () => { scopeInfo: { currency: "ARS", type: ScopeType.Exchange, - url: "http://asd" + url: "http://asd", }, withdrawalAccountsList: [], ageRestrictionOptions: [], @@ -197,8 +208,8 @@ describe("Withdraw CTA states", () => { if (state.status !== "success") return; expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.toBeSent).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.amount.value).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.doWithdrawal.onClick).not.undefined; }, @@ -229,9 +240,14 @@ describe("Withdraw CTA states", () => { { status: "pending", operationId: "123", + currency: "ARS", amount: "ARS:2" as AmountString, possibleExchanges: exchangeWithNewTos, defaultExchangeBaseUrl: exchangeWithNewTos[0].exchangeBaseUrl, + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", }, ); handler.addWalletCallResponse( @@ -244,7 +260,7 @@ describe("Withdraw CTA states", () => { scopeInfo: { currency: "ARS", type: ScopeType.Exchange, - url: "http://asd" + url: "http://asd", }, tosAccepted: false, withdrawalAccountsList: [], @@ -259,9 +275,14 @@ describe("Withdraw CTA states", () => { { status: "pending", operationId: "123", + currency: "ARS", amount: "ARS:2" as AmountString, possibleExchanges: exchanges, defaultExchangeBaseUrl: exchanges[0].exchangeBaseUrl, + editableAmount: false, + editableExchange: false, + maxAmount: "ARS:1", + wireFee: "ARS:0", }, ); @@ -281,8 +302,8 @@ describe("Withdraw CTA states", () => { if (state.status !== "success") return; expect(state.toBeReceived).deep.equal(Amounts.parseOrThrow("ARS:2")); - expect(state.withdrawalFee).deep.equal(Amounts.parseOrThrow("ARS:0")); - expect(state.chosenAmount).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.toBeSent).deep.equal(Amounts.parseOrThrow("ARS:2")); + expect(state.amount.value).deep.equal(Amounts.parseOrThrow("ARS:2")); expect(state.doWithdrawal.onClick).not.undefined; }, diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -19,6 +19,7 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Amount } from "../../components/Amount.js"; import { AmountField } from "../../components/AmountField.js"; +import { EnabledBySettings } from "../../components/EnabledBySettings.js"; import { Part } from "../../components/Part.js"; import { QR } from "../../components/QR.js"; import { SelectList } from "../../components/SelectList.js"; @@ -38,7 +39,7 @@ import { getAmountWithFee, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; -import { EnabledBySettings } from "../../components/EnabledBySettings.js"; +import { Amounts } from "@gnu-taler/taler-util"; export function FinalStateOperation(state: State.AlreadyCompleted): VNode { const { i18n } = useTranslationContext(); @@ -143,8 +144,6 @@ export function FinalStateOperation(state: State.AlreadyCompleted): VNode { export function SuccessView(state: State.Success): VNode { const { i18n } = useTranslationContext(); - // const currentTosVersionIsAccepted = - // state.currentExchange.tosStatus === ExchangeTosStatus.Accepted; return ( <Fragment> <section style={{ textAlign: "left" }}> @@ -174,6 +173,11 @@ export function SuccessView(state: State.Success): VNode { kind="neutral" big /> + {state.editableAmount ? ( + <Fragment> + <AmountField handler={state.amount} label={i18n.str`Amount`} /> + </Fragment> + ) : undefined} {state.chooseCurrencies.length > 0 ? ( <Fragment> <p> @@ -207,9 +211,10 @@ export function SuccessView(state: State.Success): VNode { conversion={state.conversionInfo?.amount} amount={getAmountWithFee( state.toBeReceived, - state.chosenAmount, + state.toBeSent, "credit", )} + bankFee={state.bankFee} /> } /> @@ -227,7 +232,6 @@ export function SuccessView(state: State.Success): VNode { </section> <section> - {/* <div> */} <TermsOfService exchangeUrl={state.currentExchange.exchangeBaseUrl}> <Button variant="contained" @@ -240,20 +244,6 @@ export function SuccessView(state: State.Success): VNode { </i18n.Translate> </Button> </TermsOfService> - {/* </div> - <div style={{ marginTop: 20 }}> - <Button - variant="text" - color="success" - - disabled={!state.doAbort.onClick} - onClick={state.doAbort.onClick} - > - <i18n.Translate> - Cancel - </i18n.Translate> - </Button> - </div> */} </section> {state.talerWithdrawUri ? ( <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} /> diff --git a/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts b/packages/taler-wallet-webextension/src/hooks/useIsOnline.ts @@ -1,7 +1,21 @@ -import { codecForBoolean } from "@gnu-taler/taler-util"; -import { buildStorageKey, useMemoryStorage } from "@gnu-taler/web-util/browser"; -import { platform } from "../platform/foreground.js"; +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { useMemoryStorage } from "@gnu-taler/web-util/browser"; import { useEffect } from "preact/hooks"; +import { platform } from "../platform/foreground.js"; export function useIsOnline(): boolean { const { value, update } = useMemoryStorage("online", true); diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -732,15 +732,35 @@ function listenNetworkConnectionState( function notifyOnline() { notify("on"); } - notify(window.navigator.onLine ? "on" : "off"); - window.addEventListener("offline", notifyOffline); - window.addEventListener("online", notifyOnline); + function notifyChange() { + if (nav.onLine) { + notifyOnline(); + } else { + notifyOnline(); + } + } + notify(navigator.onLine ? "on" : "off"); + + const nav: any = navigator; + if (typeof nav.connection !== "undefined") { + nav.connection.addEventListener("change", notifyChange); + } + if (typeof window !== "undefined") { + window.addEventListener("offline", notifyOffline); + window.addEventListener("online", notifyOnline); + } return () => { - window.removeEventListener("offline", notifyOffline); - window.removeEventListener("online", notifyOnline); + if (typeof nav.connection !== "undefined") { + nav.connection.removeEventListener("change", notifyChange); + } + if (typeof window !== "undefined") { + window.removeEventListener("offline", notifyOffline); + window.removeEventListener("online", notifyOnline); + } }; } + function runningOnPrivateMode(): boolean { return chrome.extension.inIncognitoContext; } diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts @@ -35,11 +35,11 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { keepAlive: (cb: VoidFunction) => cb(), findTalerUriInActiveTab: async () => undefined, findTalerUriInClipboard: async () => undefined, - listenNetworkConnectionState, + listenNetworkConnectionState: () => () => undefined, openNewURLFromPopup: () => undefined, triggerWalletEvent: () => undefined, setAlertedIcon: () => undefined, - setNormalIcon : () => undefined, + setNormalIcon: () => undefined, getPermissionsApi: () => ({ containsClipboardPermissions: async () => true, removeClipboardPermissions: async () => false, @@ -200,19 +200,3 @@ interface IframeMessageCommand { export default api; -function listenNetworkConnectionState( - notify: (state: "on" | "off") => void, -): () => void { - function notifyOffline() { - notify("off"); - } - function notifyOnline() { - notify("on"); - } - window.addEventListener("offline", notifyOffline); - window.addEventListener("online", notifyOnline); - return () => { - window.removeEventListener("offline", notifyOffline); - window.removeEventListener("online", notifyOnline); - }; -} diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx @@ -180,7 +180,7 @@ export function BalanceView(state: State.Balances): VNode { variant="contained" onClick={state.goToWalletManualWithdraw.onClick} > - <i18n.Translate>Add</i18n.Translate> + <i18n.Translate>Receive</i18n.Translate> </Button> {currencyWithNonZeroAmount.length > 0 && ( <MultiActionButton diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts @@ -94,9 +94,9 @@ export namespace State { currentAccount: PaytoUri; totalFee: AmountJson; - totalToDeposit: AmountJson; amount: AmountFieldHandler; + totalToDeposit: AmountFieldHandler; account: SelectFieldHandler; cancelHandler: ButtonHandler; depositHandler: ButtonHandler; diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts @@ -15,19 +15,18 @@ */ import { - AmountJson, Amounts, - DepositGroupFees, KnownBankAccountsInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri, + TransactionAmountMode } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useState } from "preact/hooks"; import { alertFromError, useAlertContext } from "../../context/alert.js"; import { useBackendContext } from "../../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useAsyncAsHook } from "../../hooks/useAsyncAsHook.js"; import { RecursiveState } from "../../utils/index.js"; import { Props, State } from "./index.js"; @@ -83,8 +82,11 @@ export function useComponentState({ if (hook.hasError) { return { status: "error", - error: alertFromError(i18n, - i18n.str`Could not load balance information`, hook), + error: alertFromError( + i18n, + i18n.str`Could not load balance information`, + hook, + ), }; } const { accounts, balances } = hook.response; @@ -141,21 +143,23 @@ export function useComponentState({ } const firstAccount = accounts[0].uri; const currentAccount = !selectedAccount ? firstAccount : selectedAccount; - - return () => { - // eslint-disable-next-line react-hooks/rules-of-hooks - const [amount, setAmount] = useState<AmountJson>( - initialValue ?? ({} as any), + const zero = Amounts.zeroOfCurrency(currency) + return (): State => { + const [instructed, setInstructed] = useState( + {amount: initialValue ?? zero, type: TransactionAmountMode.Raw}, ); - const amountStr = Amounts.stringify(amount); + const amountStr = Amounts.stringify(instructed.amount); const depositPaytoUri = stringifyPaytoUri(currentAccount); - // eslint-disable-next-line react-hooks/rules-of-hooks const hook = useAsyncAsHook(async () => { - const fee = await api.wallet.call(WalletApiOperation.PrepareDeposit, { - amount: amountStr, - depositPaytoUri, - }); + const fee = await api.wallet.call( + WalletApiOperation.ConvertDepositAmount, + { + amount: amountStr, + type: instructed.type, + depositPaytoUri, + }, + ); return { fee }; }, [amountStr, depositPaytoUri]); @@ -183,18 +187,16 @@ export function useComponentState({ const totalFee = fee !== undefined - ? Amounts.sum([fee.fees.wire, fee.fees.coin, fee.fees.refresh]).amount + ? Amounts.sub(fee.effectiveAmount, fee.rawAmount).amount : Amounts.zeroOfCurrency(currency); - const totalToDeposit = - fee !== undefined - ? Amounts.sub(amount, totalFee).amount - : Amounts.zeroOfCurrency(currency); + const totalToDeposit = Amounts.parseOrThrow(fee.rawAmount); + const totalEffective = Amounts.parseOrThrow(fee.effectiveAmount); - const isDirty = amount !== initialValue; + const isDirty = instructed.amount !== initialValue; const amountError = !isDirty ? undefined - : Amounts.cmp(balance, amount) === -1 + : Amounts.cmp(balance, totalEffective) === -1 ? `Too much, your current balance is ${Amounts.stringifyValue(balance)}` : undefined; @@ -207,7 +209,7 @@ export function useComponentState({ if (!currency) return; const depositPaytoUri = stringifyPaytoUri(currentAccount); - const amountStr = Amounts.stringify(amount); + const amountStr = Amounts.stringify(totalEffective); await api.wallet.call(WalletApiOperation.CreateDepositGroup, { amount: amountStr, depositPaytoUri, @@ -220,8 +222,19 @@ export function useComponentState({ error: undefined, currency, amount: { - value: amount, - onInput: pushAlertOnError(async (a) => setAmount(a)), + value: totalEffective, + onInput: pushAlertOnError(async (a) => setInstructed({ + amount: a, + type: TransactionAmountMode.Effective, + })), + error: amountError, + }, + totalToDeposit: { + value: totalToDeposit, + onInput: pushAlertOnError(async (a) => setInstructed({ + amount: a, + type: TransactionAmountMode.Raw, + })), error: amountError, }, onAddAccount: { @@ -244,7 +257,6 @@ export function useComponentState({ onClick: unableToDeposit ? undefined : pushAlertOnError(doSend), }, totalFee, - totalToDeposit, }; }; } @@ -269,7 +281,7 @@ export function createLabelsForBankAccount( ): { [value: string]: string } { const initialList: Record<string, string> = {}; if (!knownBankAccounts.length) return initialList; - return knownBankAccounts.reduce((prev, cur, i) => { + return knownBankAccounts.reduce((prev, cur) => { prev[stringifyPaytoUri(cur.uri)] = cur.alias; return prev; }, initialList); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/stories.tsx @@ -53,7 +53,10 @@ export const WithNoAccountForIBAN = tests.createExample(ReadyView, { onClick: nullFunction, }, totalFee: Amounts.zeroOfCurrency("USD"), - totalToDeposit: Amounts.parseOrThrow("USD:10"), + totalToDeposit: { + onInput:nullFunction, + value: Amounts.parseOrThrow("USD:10"), + }, // onCalculateFee: alwaysReturnFeeToOne, }); @@ -82,7 +85,10 @@ export const WithIBANAccountTypeSelected = tests.createExample(ReadyView, { onClick: nullFunction, }, totalFee: Amounts.zeroOfCurrency("USD"), - totalToDeposit: Amounts.parseOrThrow("USD:10"), + totalToDeposit: { + onInput:nullFunction, + value: Amounts.parseOrThrow("USD:10"), + }, // onCalculateFee: alwaysReturnFeeToOne, }); @@ -111,6 +117,9 @@ export const NewBitcoinAccountTypeSelected = tests.createExample(ReadyView, { onClick: nullFunction, }, totalFee: Amounts.zeroOfCurrency("USD"), - totalToDeposit: Amounts.parseOrThrow("USD:10"), + totalToDeposit: { + onInput:nullFunction, + value: Amounts.parseOrThrow("USD:10"), + }, // onCalculateFee: alwaysReturnFeeToOne, }); diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/test.ts @@ -20,17 +20,16 @@ */ import { + AmountResponse, Amounts, AmountString, - DepositGroupFees, parsePaytoUri, - PrepareDepositResponse, ScopeType, - stringifyPaytoUri, + stringifyPaytoUri } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { expect } from "chai"; import * as tests from "@gnu-taler/web-util/testing"; +import { expect } from "chai"; import { nullFunction } from "../../mui/handlers.js"; import { createWalletApiMock } from "../../test-utils.js"; @@ -38,24 +37,14 @@ import { useComponentState } from "./state.js"; const currency = "EUR"; const amount = `${currency}:0`; -const withoutFee = (): PrepareDepositResponse => ({ - effectiveDepositAmount: `${currency}:5` as AmountString, - totalDepositCost: `${currency}:5` as AmountString, - fees: { - coin: Amounts.stringify(`${currency}:0`), - wire: Amounts.stringify(`${currency}:0`), - refresh: Amounts.stringify(`${currency}:0`), - }, +const withoutFee = (value: number): AmountResponse => ({ + effectiveAmount: `${currency}:${value}` as AmountString, + rawAmount: `${currency}:${value}` as AmountString, }); -const withSomeFee = (): PrepareDepositResponse => ({ - effectiveDepositAmount: `${currency}:5` as AmountString, - totalDepositCost: `${currency}:5` as AmountString, - fees: { - coin: Amounts.stringify(`${currency}:1`), - wire: Amounts.stringify(`${currency}:1`), - refresh: Amounts.stringify(`${currency}:1`), - }, +const withSomeFee = (value: number, fee: number): AmountResponse => ({ + effectiveAmount: `${currency}:${value}` as AmountString, + rawAmount: `${currency}:${value - fee}` as AmountString, }); describe("DepositPage states", () => { @@ -195,9 +184,9 @@ describe("DepositPage states", () => { }, ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); const hookBehavior = await tests.hookBehaveLikeThis( @@ -255,15 +244,15 @@ describe("DepositPage states", () => { }, ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); const accountSelected = stringifyPaytoUri(ibanPayto.uri); @@ -345,19 +334,19 @@ describe("DepositPage states", () => { }, ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withoutFee(), + withoutFee(0), ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withSomeFee(), + withSomeFee(10,3), ); handler.addWalletCallResponse( - WalletApiOperation.PrepareDeposit, + WalletApiOperation.ConvertDepositAmount, undefined, - withSomeFee(), + withSomeFee(10,3), ); const accountSelected = stringifyPaytoUri(ibanPayto.uri); @@ -404,7 +393,7 @@ describe("DepositPage states", () => { expect(state.account.value).eq(accountSelected); expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:10")); expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); - expect(state.totalToDeposit).deep.eq( + expect(state.totalToDeposit.value).deep.eq( Amounts.parseOrThrow(`${currency}:7`), ); expect(state.depositHandler.onClick).not.undefined; @@ -416,7 +405,7 @@ describe("DepositPage states", () => { expect(state.account.value).eq(accountSelected); expect(state.amount.value).deep.eq(Amounts.parseOrThrow("EUR:10")); expect(state.totalFee).deep.eq(Amounts.parseOrThrow(`${currency}:3`)); - expect(state.totalToDeposit).deep.eq( + expect(state.totalToDeposit.value).deep.eq( Amounts.parseOrThrow(`${currency}:7`), ); expect(state.depositHandler.onClick).not.undefined; diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx b/packages/taler-wallet-webextension/src/wallet/DepositPage/views.tsx @@ -26,7 +26,7 @@ import { Grid } from "../../mui/Grid.js"; import { State } from "./index.js"; export function AmountOrCurrencyErrorView( - p: State.AmountOrCurrencyError, + _p: State.AmountOrCurrencyError, ): VNode { const { i18n } = useTranslationContext(); @@ -145,7 +145,7 @@ export function ReadyView(state: State.Ready): VNode { </p> <Grid container spacing={2} columns={1}> <Grid item xs={1}> - <AmountField label={i18n.str`Amount`} handler={state.amount} /> + <AmountField label={i18n.str`Brut amount`} handler={state.amount} /> </Grid> <Grid item xs={1}> <AmountField @@ -156,12 +156,7 @@ export function ReadyView(state: State.Ready): VNode { /> </Grid> <Grid item xs={1}> - <AmountField - label={i18n.str`Total deposit`} - handler={{ - value: state.totalToDeposit, - }} - /> + <AmountField label={i18n.str`Net amount`} handler={state.totalToDeposit} /> </Grid> </Grid> </section> @@ -180,7 +175,7 @@ export function ReadyView(state: State.Ready): VNode { ) : ( <Button variant="contained" onClick={state.depositHandler.onClick}> <i18n.Translate> - Deposit&nbsp;{Amounts.stringifyValue(state.totalToDeposit)}{" "} + Deposit&nbsp;{Amounts.stringifyValue(state.totalToDeposit.value)}{" "} {state.currency} </i18n.Translate> </Button> diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx @@ -17,13 +17,12 @@ import { AbsoluteTime, Amounts, - CoinDumpJson, CoinStatus, ExchangeTosStatus, LogLevel, NotificationType, ScopeType, - stringifyWithdrawExchange, + stringifyWithdrawExchange } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -52,7 +51,6 @@ import { Grid } from "../mui/Grid.js"; import { Paper } from "../mui/Paper.js"; import { TextField } from "../mui/TextField.js"; -type CoinsInfo = CoinDumpJson["coins"]; type CalculatedCoinfInfo = { // ageKeysCount: number | undefined; denom_value: number; @@ -68,15 +66,7 @@ type SplitedCoinInfo = { usable: CalculatedCoinfInfo[]; }; -export interface Props { - // FIXME: Pending operations don't exist anymore. -} - -function hashObjectId(o: any): string { - return JSON.stringify(o); -} - -export function DeveloperPage({}: Props): VNode { +export function DeveloperPage(): VNode { const { i18n } = useTranslationContext(); const [downloadedDatabase, setDownloadedDatabase] = useState< { time: Date; content: string } | undefined @@ -361,6 +351,7 @@ export function DeveloperPage({}: Props): VNode { <a href={new URL(`/keys`, e.exchangeBaseUrl).href} target="_blank" + rel="noreferrer" > {e.exchangeBaseUrl} </a> diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx @@ -130,7 +130,6 @@ export function ReadyView({ ))} </div> <div style={{ border: "1px solid gray", padding: 8, borderRadius: 5 }}> - --- {uri.value} --- <p> <CustomFieldByAccountType type={accountType.value as AccountType} diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -1416,9 +1416,11 @@ export function TransferPickupDetails({ export function WithdrawDetails({ conversion, amount, + bankFee, }: { conversion?: AmountJson; amount: AmountWithFee; + bankFee?: AmountJson; }): VNode { const { i18n } = useTranslationContext(); @@ -1481,6 +1483,16 @@ export function WithdrawDetails({ </tr> </Fragment> )} + {!bankFee ? undefined : ( + <tr> + <td> + <i18n.Translate>Bank fee</i18n.Translate> + </td> + <td> + <Amount value={bankFee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + )} </PurchaseDetailsTable> ); } diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts @@ -55,7 +55,7 @@ import { WalletActivityTrack } from "./wxBackend.js"; const logger = new Logger("wxApi"); -export const WALLET_CORE_SUPPORTED_VERSION = "4:0:0" +export const WALLET_CORE_SUPPORTED_VERSION = "5:0:0" export interface ExtendedPermissionsResponse { newValue: boolean; diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -39,7 +39,7 @@ import { makeErrorDetail, openPromise, setGlobalLogLevelFromString, - setLogLevelFromString + setLogLevelFromString, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { @@ -92,7 +92,7 @@ async function resetDb(): Promise<void> { export type WalletActivityTrack = { id: number; - events: (WalletNotification & {when: AbsoluteTime})[]; + events: (WalletNotification & { when: AbsoluteTime })[]; start: AbsoluteTime; type: NotificationType; end: AbsoluteTime; @@ -107,130 +107,138 @@ function getUniqueId(): number { //FIXME: maybe circular buffer const activity: WalletActivityTrack[] = []; -function addNewWalletActivityNotification(list: WalletActivityTrack[], n: WalletNotification) { - const start = AbsoluteTime.now(); - const ev = {...n, when:start}; - switch (n.type) { +function convertWalletActivityNotification( + knownEvents: WalletActivityTrack[], + event: WalletNotification & { + when: AbsoluteTime; + }, +): WalletActivityTrack | undefined { + switch (event.type) { case NotificationType.BalanceChange: { - const groupId = `${n.type}:${n.hintTransactionId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.hintTransactionId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.BackupOperationError: { const groupId = ""; - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.TransactionStateTransition: { - const groupId = `${n.type}:${n.transactionId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.transactionId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.WithdrawalOperationTransition: { - return; + return undefined; } case NotificationType.ExchangeStateTransition: { - const groupId = `${n.type}:${n.exchangeBaseUrl}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.exchangeBaseUrl}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return { id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, - }); - return; + }; } case NotificationType.Idle: { const groupId = ""; - list.push({ + return({ id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, }); - return; } case NotificationType.TaskObservabilityEvent: { - const groupId = `${n.type}:${n.taskId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.taskId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return({ id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, }); - return; } case NotificationType.RequestObservabilityEvent: { - const groupId = `${n.type}:${n.operation}:${n.requestId}`; - const found = list.find((a)=>a.groupId === groupId) + const groupId = `${event.type}:${event.operation}:${event.requestId}`; + const found = knownEvents.find((a) => a.groupId === groupId); if (found) { - found.end = start; - found.events.unshift(ev) - return; + found.end = event.when; + found.events.unshift(event); + return found; } - list.push({ + return({ id: getUniqueId(), - type: n.type, - start, + type: event.type, + start: event.when, end: AbsoluteTime.never(), - events: [ev], + events: [event], groupId, }); - return; } } } +function addNewWalletActivityNotification( + list: WalletActivityTrack[], + n: WalletNotification, +) { + const start = AbsoluteTime.now(); + const ev = { ...n, when: start }; + const activity = convertWalletActivityNotification(list, ev); + if (activity) { + list.unshift(activity); // insert at start + } +} + async function getNotifications({ filter, }: { diff --git a/packages/web-util/package.json b/packages/web-util/package.json @@ -1,6 +1,6 @@ { "name": "@gnu-taler/web-util", - "version": "0.10.7", + "version": "0.11.4", "description": "Generic helper functionality for GNU Taler Web Apps", "type": "module", "types": "./lib/index.node.d.ts", diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx @@ -21,7 +21,7 @@ export function CopyButton({ class: clazz, children, getContent }: { children?: const [copied, setCopied] = useState(false); function copyText(): void { if (!navigator.clipboard && !window.isSecureContext) { - alert('clipboard is not available on insecure context (http)') + prompt("Clipboard is not available on insecure context (http).", getContent()); } if (navigator.clipboard) { navigator.clipboard.writeText(getContent() || ""); diff --git a/packages/web-util/src/index.build.ts b/packages/web-util/src/index.build.ts @@ -305,8 +305,10 @@ export function computeConfig(params: BuildParams): esbuild.BuildOptions { /** * Build sources for prod environment */ -export function build(config: BuildParams) { - return esbuild.build(computeConfig(config)); +export async function build(config: BuildParams) { + const res = await esbuild.build(computeConfig(config)); + fs.writeFileSync(`${config.destination}/version.txt`, `${_package.version}`); + return res; } const LIVE_RELOAD_SCRIPT = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -40,10 +40,10 @@ importers: version: link:../web-util '@headlessui/react': specifier: ^1.7.14 - version: 1.7.14(react-dom@18.2.0)(react@18.2.0) + version: 1.7.14(react-dom@18.3.1)(react@18.3.1) '@heroicons/react': specifier: ^2.0.17 - version: 2.0.17(react@18.2.0) + version: 2.0.17(react@18.3.1) date-fns: specifier: 2.29.3 version: 2.29.3 @@ -58,7 +58,7 @@ importers: version: 10.11.3 swr: specifier: 2.2.2 - version: 2.2.2(react@18.2.0) + version: 2.2.2(react@18.3.1) devDependencies: '@gnu-taler/pogen': specifier: workspace:* @@ -254,7 +254,7 @@ importers: version: 1.4.4 swr: specifier: 2.2.2 - version: 2.2.2(react@18.2.0) + version: 2.2.2(react@18.3.1) yup: specifier: ^0.32.9 version: 0.32.11 @@ -285,7 +285,7 @@ importers: version: 4.33.0(eslint@7.32.0)(typescript@5.3.3) base64-inline-loader: specifier: ^1.1.1 - version: 1.1.1(webpack@4.47.0) + version: 1.1.1(webpack@5.91.0) bulma: specifier: ^0.9.2 version: 0.9.4 @@ -330,7 +330,7 @@ importers: version: 0.0.10 html-webpack-skip-assets-plugin: specifier: ^1.0.1 - version: 1.0.3(html-webpack-plugin@5.5.4)(webpack@4.47.0) + version: 1.0.3(html-webpack-plugin@5.6.0)(webpack@5.91.0) inline-chunk-html-plugin: specifier: ^1.1.1 version: 1.1.1 @@ -375,7 +375,7 @@ importers: version: 1.4.4 swr: specifier: 2.0.3 - version: 2.0.3(react@18.2.0) + version: 2.0.3(react@18.3.1) devDependencies: '@gnu-taler/pogen': specifier: workspace:* @@ -406,7 +406,7 @@ importers: version: 6.19.0(eslint@8.56.0)(typescript@5.3.3) autoprefixer: specifier: ^10.4.14 - version: 10.4.14(postcss@8.4.33) + version: 10.4.14(postcss@8.4.38) chai: specifier: ^4.3.6 version: 4.3.6 @@ -457,7 +457,7 @@ importers: version: 1.4.4 swr: specifier: 2.0.3 - version: 2.0.3(react@18.2.0) + version: 2.0.3(react@18.3.1) devDependencies: '@gnu-taler/pogen': specifier: workspace:* @@ -488,7 +488,7 @@ importers: version: 6.19.0(eslint@8.56.0)(typescript@5.3.3) autoprefixer: specifier: ^10.4.14 - version: 10.4.14(postcss@8.4.33) + version: 10.4.14(postcss@8.4.38) chai: specifier: ^4.3.6 version: 4.3.6 @@ -524,8 +524,8 @@ importers: version: 2.6.2 optionalDependencies: better-sqlite3: - specifier: 9.4.0 - version: 9.4.0 + specifier: 10.0.0 + version: 10.0.0 devDependencies: '@types/better-sqlite3': specifier: ^7.6.8 @@ -569,13 +569,13 @@ importers: version: 3.0.0-beta.22 '@linaria/react': specifier: 3.0.0-beta.22 - version: 3.0.0-beta.22(react@18.2.0) + version: 3.0.0-beta.22(react@18.3.1) '@linaria/shaker': specifier: 3.0.0-beta.22 version: 3.0.0-beta.22 '@linaria/webpack-loader': specifier: 3.0.0-beta.22 - version: 3.0.0-beta.22(webpack@4.47.0) + version: 3.0.0-beta.22(webpack@5.91.0) '@types/mocha': specifier: ^8.2.2 version: 8.2.3 @@ -593,10 +593,10 @@ importers: version: 4.33.0(eslint@7.32.0)(typescript@5.3.3) babel-loader: specifier: ^8.2.2 - version: 8.2.5(@babel/core@7.18.9)(webpack@4.47.0) + version: 8.2.5(@babel/core@7.18.9)(webpack@5.91.0) base64-inline-loader: specifier: ^1.1.1 - version: 1.1.1(webpack@4.47.0) + version: 1.1.1(webpack@5.91.0) eslint: specifier: ^7.25.0 version: 7.32.0 @@ -656,7 +656,7 @@ importers: version: 1.4.4 swr: specifier: 2.2.2 - version: 2.2.2(react@18.2.0) + version: 2.2.2(react@18.3.1) yup: specifier: ^0.32.9 version: 0.32.11 @@ -687,7 +687,7 @@ importers: version: 6.19.0(eslint@8.56.0)(typescript@5.3.3) base64-inline-loader: specifier: ^1.1.1 - version: 1.1.1(webpack@4.47.0) + version: 1.1.1(webpack@5.91.0) bulma: specifier: ^0.9.2 version: 0.9.4 @@ -732,7 +732,7 @@ importers: version: 0.0.10 html-webpack-skip-assets-plugin: specifier: ^1.0.1 - version: 1.0.3(html-webpack-plugin@5.5.4)(webpack@4.47.0) + version: 1.0.3(html-webpack-plugin@5.6.0)(webpack@5.91.0) inline-chunk-html-plugin: specifier: ^1.1.1 version: 1.1.1 @@ -1000,10 +1000,10 @@ importers: devDependencies: '@babel/preset-react': specifier: ^7.22.3 - version: 7.22.3(@babel/core@7.18.9) + version: 7.22.3(@babel/core@7.24.6) '@babel/preset-typescript': specifier: 7.18.6 - version: 7.18.6(@babel/core@7.18.9) + version: 7.18.6(@babel/core@7.24.6) '@gnu-taler/pogen': specifier: workspace:* version: link:../pogen @@ -1021,7 +1021,7 @@ importers: version: 5.0.4(esbuild@0.19.9) '@linaria/react': specifier: 5.0.3 - version: 5.0.3(react@18.2.0) + version: 5.0.3(react@18.3.1) '@linaria/shaker': specifier: 5.0.3 version: 5.0.3 @@ -1109,7 +1109,7 @@ importers: version: link:../taler-util '@heroicons/react': specifier: ^2.0.17 - version: 2.0.17(react@18.2.0) + version: 2.0.17(react@18.3.1) '@linaria/babel-preset': specifier: 5.0.4 version: 5.0.4 @@ -1121,7 +1121,7 @@ importers: version: 5.0.4(esbuild@0.19.9) '@linaria/react': specifier: 5.0.3 - version: 5.0.3(react@18.2.0) + version: 5.0.3(react@18.3.1) '@types/express': specifier: ^4.17.14 version: 4.17.14 @@ -1169,7 +1169,7 @@ importers: version: 1.56.1 swr: specifier: 2.0.3 - version: 2.0.3(react@18.2.0) + version: 2.0.3(react@18.3.1) tslib: specifier: ^2.6.2 version: 2.6.2 @@ -1198,6 +1198,14 @@ packages: '@jridgewell/gen-mapping': 0.1.1 '@jridgewell/trace-mapping': 0.3.19 + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + /@apideck/better-ajv-errors@0.3.6(ajv@8.11.0): resolution: {integrity: sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==} engines: {node: '>=10'} @@ -1244,6 +1252,14 @@ packages: '@babel/highlight': 7.23.4 chalk: 2.4.2 + /@babel/code-frame@7.24.6: + resolution: {integrity: sha512-ZJhac6FkEd1yhG2AHOmfcXG4ceoLltoCVJjN5XsWN9BifBQr+cHJbWi0h68HZuSORq+3WtJ2z0hwF2NG1b5kcA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.6 + picocolors: 1.0.1 + dev: true + /@babel/compat-data@7.19.4: resolution: {integrity: sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw==} engines: {node: '>=6.9.0'} @@ -1258,6 +1274,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/compat-data@7.24.6: + resolution: {integrity: sha512-aC2DGhBq5eEdyXWqrDInSqQjO0k8xtPRf5YylULqx8MCd6jBtzqfta/3ETMRpuKIc5hyswfO80ObyA1MvkCcUQ==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/core@7.13.16: resolution: {integrity: sha512-sXHpixBiWWFti0AV2Zq7avpTasr6sIAu7Y396c608541qAU2ui4a193m0KSQmfPSKFZLnQ3cvlKDOm3XkuXm3Q==} engines: {node: '>=6.9.0'} @@ -1349,6 +1370,29 @@ packages: - supports-color dev: true + /@babel/core@7.24.6: + resolution: {integrity: sha512-qAHSfAdVyFmIvl0VHELib8xar7ONuSHrE2hLnsaWkYNTI68dmi1x8GYDhJjMI/e7XWal9QBlZkwbOnkcw7Z8gQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.6 + '@babel/generator': 7.24.6 + '@babel/helper-compilation-targets': 7.24.6 + '@babel/helper-module-transforms': 7.24.6(@babel/core@7.24.6) + '@babel/helpers': 7.24.6 + '@babel/parser': 7.24.6 + '@babel/template': 7.24.6 + '@babel/traverse': 7.24.6 + '@babel/types': 7.24.6 + convert-source-map: 2.0.0 + debug: 4.3.4 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/eslint-parser@7.19.1(@babel/core@7.18.9)(eslint@7.32.0): resolution: {integrity: sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==} engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} @@ -1400,6 +1444,16 @@ packages: '@jridgewell/trace-mapping': 0.3.19 jsesc: 2.5.2 + /@babel/generator@7.24.6: + resolution: {integrity: sha512-S7m4eNa6YAPJRHmKsLHIDJhNAGNKoWNiWefz1MBbpnt8g9lvMDl1hir4P9bo/57bQEmuwEhnRU/AMWsD0G/Fbg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.6 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + dev: true + /@babel/helper-annotate-as-pure@7.22.5: resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} engines: {node: '>=6.9.0'} @@ -1477,6 +1531,17 @@ packages: semver: 6.3.1 dev: true + /@babel/helper-compilation-targets@7.24.6: + resolution: {integrity: sha512-VZQ57UsDGlX/5fFA7GkVPplZhHsVc+vuErWgdOiysI9Ksnw0Pbbd6pnPiR/mmJyKHgyIW0c7KT32gmhiF+cirg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.6 + '@babel/helper-validator-option': 7.24.6 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + /@babel/helper-create-class-features-plugin@7.23.5(@babel/core@7.18.9): resolution: {integrity: sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==} engines: {node: '>=6.9.0'} @@ -1531,6 +1596,24 @@ packages: semver: 6.3.1 dev: true + /@babel/helper-create-class-features-plugin@7.23.5(@babel/core@7.24.6): + resolution: {integrity: sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.22.20(@babel/core@7.24.6) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + semver: 6.3.1 + dev: true + /@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.18.9): resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==} engines: {node: '>=6.9.0'} @@ -1622,6 +1705,11 @@ packages: resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} engines: {node: '>=6.9.0'} + /@babel/helper-environment-visitor@7.24.6: + resolution: {integrity: sha512-Y50Cg3k0LKLMjxdPjIl40SdJgMB85iXn27Vk/qbHZCFx/o5XO3PSnpi675h1KEmmDb6OFArfd5SCQEQ5Q4H88g==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-function-name@7.19.0: resolution: {integrity: sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==} engines: {node: '>=6.9.0'} @@ -1637,6 +1725,14 @@ packages: '@babel/template': 7.22.15 '@babel/types': 7.23.5 + /@babel/helper-function-name@7.24.6: + resolution: {integrity: sha512-xpeLqeeRkbxhnYimfr2PC+iA0Q7ljX/d1eZ9/inYbmfG2jpl8Lu3DyXvpOAnrS5kxkfOWJjioIMQsaMBXFI05w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.6 + '@babel/types': 7.24.6 + dev: true + /@babel/helper-hoist-variables@7.18.6: resolution: {integrity: sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==} engines: {node: '>=6.9.0'} @@ -1650,6 +1746,13 @@ packages: dependencies: '@babel/types': 7.23.5 + /@babel/helper-hoist-variables@7.24.6: + resolution: {integrity: sha512-SF/EMrC3OD7dSta1bLJIlrsVxwtd0UpjRJqLno6125epQMJ/kyFmpTT4pbvPbdQHzCHg+biQ7Syo8lnDtbR+uA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.6 + dev: true + /@babel/helper-member-expression-to-functions@7.23.0: resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==} engines: {node: '>=6.9.0'} @@ -1677,6 +1780,13 @@ packages: dependencies: '@babel/types': 7.23.5 + /@babel/helper-module-imports@7.24.6: + resolution: {integrity: sha512-a26dmxFJBF62rRO9mmpgrfTLsAuyHk4e1hKTUkD/fcMfynt8gvEKwQPQDVxWhca8dHoDck+55DFt42zV0QMw5g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.6 + dev: true + /@babel/helper-module-transforms@7.19.6: resolution: {integrity: sha512-fCmcfQo/KYr/VXXDIyd3CBGZ6AFhPFy1TfSEJ+PilGVlQT6jcbqtHAM4C1EciRqMza7/TpOUZliuSH+U6HAhJw==} engines: {node: '>=6.9.0'} @@ -1750,6 +1860,20 @@ packages: '@babel/helper-validator-identifier': 7.22.20 dev: true + /@babel/helper-module-transforms@7.24.6(@babel/core@7.24.6): + resolution: {integrity: sha512-Y/YMPm83mV2HJTbX1Qh2sjgjqcacvOlhbzdCCsSlblOKjSYmQqEbO6rUniWQyRo9ncyfjT8hnUjlG06RXDEmcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-environment-visitor': 7.24.6 + '@babel/helper-module-imports': 7.24.6 + '@babel/helper-simple-access': 7.24.6 + '@babel/helper-split-export-declaration': 7.24.6 + '@babel/helper-validator-identifier': 7.24.6 + dev: true + /@babel/helper-optimise-call-expression@7.22.5: resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} engines: {node: '>=6.9.0'} @@ -1844,6 +1968,18 @@ packages: '@babel/helper-optimise-call-expression': 7.22.5 dev: true + /@babel/helper-replace-supers@7.22.20(@babel/core@7.24.6): + resolution: {integrity: sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + dev: true + /@babel/helper-simple-access@7.19.4: resolution: {integrity: sha512-f9Xq6WqBFqaDfbCzn2w85hwklswz5qsKlh7f08w4Y9yhJHpnNC0QemtSkK5YyOY8kPGvyiwdzZksGUhnGdaUIg==} engines: {node: '>=6.9.0'} @@ -1857,6 +1993,13 @@ packages: dependencies: '@babel/types': 7.23.5 + /@babel/helper-simple-access@7.24.6: + resolution: {integrity: sha512-nZzcMMD4ZhmB35MOOzQuiGO5RzL6tJbsT37Zx8M5L/i9KSrukGXWTjLe1knIbb/RmxoJE9GON9soq0c0VEMM5g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.6 + dev: true + /@babel/helper-skip-transparent-expression-wrappers@7.22.5: resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==} engines: {node: '>=6.9.0'} @@ -1877,6 +2020,13 @@ packages: dependencies: '@babel/types': 7.23.5 + /@babel/helper-split-export-declaration@7.24.6: + resolution: {integrity: sha512-CvLSkwXGWnYlF9+J3iZUvwgAxKiYzK3BWuo+mLzD/MDGOZDj7Gq8+hqaOkMxmJwmlv0iu86uH5fdADd9Hxkymw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.6 + dev: true + /@babel/helper-string-parser@7.19.4: resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==} engines: {node: '>=6.9.0'} @@ -1886,6 +2036,11 @@ packages: resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} engines: {node: '>=6.9.0'} + /@babel/helper-string-parser@7.24.6: + resolution: {integrity: sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-identifier@7.19.1: resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} engines: {node: '>=6.9.0'} @@ -1895,6 +2050,11 @@ packages: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} + /@babel/helper-validator-identifier@7.24.6: + resolution: {integrity: sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-validator-option@7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} engines: {node: '>=6.9.0'} @@ -1909,6 +2069,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /@babel/helper-validator-option@7.24.6: + resolution: {integrity: sha512-Jktc8KkF3zIkePb48QO+IapbXlSapOW9S+ogZZkcO6bABgYAxtZcjZ/O005111YLf+j4M84uEgwYoidDkXbCkQ==} + engines: {node: '>=6.9.0'} + dev: true + /@babel/helper-wrap-function@7.22.20: resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==} engines: {node: '>=6.9.0'} @@ -1950,6 +2115,14 @@ packages: - supports-color dev: true + /@babel/helpers@7.24.6: + resolution: {integrity: sha512-V2PI+NqnyFu1i0GyTd/O/cTpxzQCYioSkUIRmgo7gFEHKKCg5w46+r/A6WeUR1+P3TeQ49dspGPNd/E3n9AnnA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.6 + '@babel/types': 7.24.6 + dev: true + /@babel/highlight@7.18.6: resolution: {integrity: sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==} engines: {node: '>=6.9.0'} @@ -1966,6 +2139,16 @@ packages: chalk: 2.4.2 js-tokens: 4.0.0 + /@babel/highlight@7.24.6: + resolution: {integrity: sha512-2YnuOp4HAk2BsBrJJvYCbItHx0zWscI1C3zgWkz+wDyD9I7GIVrfnLyrR4Y1VR+7p+chAEcrgRQYZAGIKMV7vQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.24.6 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.1 + dev: true + /@babel/parser@7.19.6: resolution: {integrity: sha512-h1IUp81s2JYJ3mRkdxJgs4UvmSsRvDrx5ICSJbPvtWYv5i1nTBGcBpnog+89rAFMwvvru6E5NUHdBe01UeSzYA==} engines: {node: '>=6.0.0'} @@ -1986,6 +2169,14 @@ packages: dependencies: '@babel/types': 7.23.5 + /@babel/parser@7.24.6: + resolution: {integrity: sha512-eNZXdfU35nJC2h24RznROuOpO94h6x8sg9ju0tT9biNtLZ2vuP8SduLqqV+/8+cebSLV9SJEAN5Z3zQbJG/M+Q==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.6 + dev: true + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.18.6(@babel/core@7.22.1): resolution: {integrity: sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==} engines: {node: '>=6.9.0'} @@ -2564,6 +2755,16 @@ packages: '@babel/helper-plugin-utils': 7.21.5 dev: true + /@babel/plugin-syntax-jsx@7.21.4(@babel/core@7.24.6): + resolution: {integrity: sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-plugin-utils': 7.21.5 + dev: true + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.18.9): resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} peerDependencies: @@ -2786,23 +2987,23 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-typescript@7.20.0(@babel/core@7.18.9): + /@babel/plugin-syntax-typescript@7.20.0(@babel/core@7.22.1): resolution: {integrity: sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.9 + '@babel/core': 7.22.1 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-syntax-typescript@7.20.0(@babel/core@7.22.1): + /@babel/plugin-syntax-typescript@7.20.0(@babel/core@7.24.6): resolution: {integrity: sha512-rd9TkG+u1CExzS4SM1BlMEhMXwFLKVjOAFFCDx9PbX5ycJWDoWMcwdJH9RhkPu1dOgn5TrxLot/Gx6lWFuAUNQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.22.1 + '@babel/core': 7.24.6 '@babel/helper-plugin-utils': 7.22.5 dev: true @@ -3990,6 +4191,16 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true + /@babel/plugin-transform-react-display-name@7.18.6(@babel/core@7.24.6): + resolution: {integrity: sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + /@babel/plugin-transform-react-jsx-development@7.18.6(@babel/core@7.18.9): resolution: {integrity: sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==} engines: {node: '>=6.9.0'} @@ -4000,6 +4211,16 @@ packages: '@babel/plugin-transform-react-jsx': 7.22.3(@babel/core@7.18.9) dev: true + /@babel/plugin-transform-react-jsx-development@7.18.6(@babel/core@7.24.6): + resolution: {integrity: sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.6 + '@babel/plugin-transform-react-jsx': 7.22.3(@babel/core@7.24.6) + dev: true + /@babel/plugin-transform-react-jsx@7.19.0(@babel/core@7.22.1): resolution: {integrity: sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==} engines: {node: '>=6.9.0'} @@ -4028,6 +4249,20 @@ packages: '@babel/types': 7.23.5 dev: true + /@babel/plugin-transform-react-jsx@7.22.3(@babel/core@7.24.6): + resolution: {integrity: sha512-JEulRWG2f04a7L8VWaOngWiK6p+JOSpB+DAtwfJgOaej1qdbNxqtK7MwTBHjUA10NeFcszlFNqCdbRcirzh2uQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-module-imports': 7.22.15 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.24.6) + '@babel/types': 7.23.5 + dev: true + /@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.18.9): resolution: {integrity: sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==} engines: {node: '>=6.9.0'} @@ -4039,6 +4274,17 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true + /@babel/plugin-transform-react-pure-annotations@7.18.6(@babel/core@7.24.6): + resolution: {integrity: sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + /@babel/plugin-transform-regenerator@7.18.6(@babel/core@7.22.1): resolution: {integrity: sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==} engines: {node: '>=6.9.0'} @@ -4306,28 +4552,28 @@ packages: '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-transform-typescript@7.20.13(@babel/core@7.18.9): + /@babel/plugin-transform-typescript@7.20.13(@babel/core@7.22.1): resolution: {integrity: sha512-O7I/THxarGcDZxkgWKMUrk7NK1/WbHAg3Xx86gqS6x9MTrNL6AwIluuZ96ms4xeDe6AVx6rjHbWHP7x26EPQBA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.9 - '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.18.9) + '@babel/core': 7.22.1 + '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1) '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.18.9) + '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.22.1) dev: true - /@babel/plugin-transform-typescript@7.20.13(@babel/core@7.22.1): + /@babel/plugin-transform-typescript@7.20.13(@babel/core@7.24.6): resolution: {integrity: sha512-O7I/THxarGcDZxkgWKMUrk7NK1/WbHAg3Xx86gqS6x9MTrNL6AwIluuZ96ms4xeDe6AVx6rjHbWHP7x26EPQBA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.22.1 - '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.22.1) + '@babel/core': 7.24.6 + '@babel/helper-create-class-features-plugin': 7.23.5(@babel/core@7.24.6) '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.22.1) + '@babel/plugin-syntax-typescript': 7.20.0(@babel/core@7.24.6) dev: true /@babel/plugin-transform-typescript@7.22.3(@babel/core@7.18.9): @@ -4768,16 +5014,19 @@ packages: '@babel/plugin-transform-react-pure-annotations': 7.18.6(@babel/core@7.18.9) dev: true - /@babel/preset-typescript@7.18.6(@babel/core@7.18.9): - resolution: {integrity: sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==} + /@babel/preset-react@7.22.3(@babel/core@7.24.6): + resolution: {integrity: sha512-lxDz1mnZ9polqClBCVBjIVUypoB4qV3/tZUDb/IlYbW1kiiLaXaX+bInbRjl+lNQ/iUZraQ3+S8daEmoELMWug==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.18.9 - '@babel/helper-plugin-utils': 7.19.0 - '@babel/helper-validator-option': 7.18.6 - '@babel/plugin-transform-typescript': 7.20.13(@babel/core@7.18.9) + '@babel/core': 7.24.6 + '@babel/helper-plugin-utils': 7.21.5 + '@babel/helper-validator-option': 7.21.0 + '@babel/plugin-transform-react-display-name': 7.18.6(@babel/core@7.24.6) + '@babel/plugin-transform-react-jsx': 7.22.3(@babel/core@7.24.6) + '@babel/plugin-transform-react-jsx-development': 7.18.6(@babel/core@7.24.6) + '@babel/plugin-transform-react-pure-annotations': 7.18.6(@babel/core@7.24.6) dev: true /@babel/preset-typescript@7.18.6(@babel/core@7.22.1): @@ -4792,6 +5041,18 @@ packages: '@babel/plugin-transform-typescript': 7.20.13(@babel/core@7.22.1) dev: true + /@babel/preset-typescript@7.18.6(@babel/core@7.24.6): + resolution: {integrity: sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.24.6 + '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-validator-option': 7.18.6 + '@babel/plugin-transform-typescript': 7.20.13(@babel/core@7.24.6) + dev: true + /@babel/preset-typescript@7.21.5(@babel/core@7.18.9): resolution: {integrity: sha512-iqe3sETat5EOrORXiQ6rWfoOg2y68Cs75B9wNxdPW4kixJxh7aXQE1KPdWLDniC24T/6dSnguF33W9j/ZZQcmA==} engines: {node: '>=6.9.0'} @@ -4862,6 +5123,15 @@ packages: '@babel/parser': 7.23.5 '@babel/types': 7.23.5 + /@babel/template@7.24.6: + resolution: {integrity: sha512-3vgazJlLwNXi9jhrR1ef8qiB65L1RK90+lEQwv4OxveHnqC3BfmnHdgySwRLzf6akhlOYenT+b7AfWq+a//AHw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.6 + '@babel/parser': 7.24.6 + '@babel/types': 7.24.6 + dev: true + /@babel/traverse@7.19.6: resolution: {integrity: sha512-6l5HrUCzFM04mfbG09AagtYyR2P0B71B1wN7PfSPiksDPz2k5H9CBC1tcZpz2M8OxbKTPccByoOJ22rUKbpmQQ==} engines: {node: '>=6.9.0'} @@ -4914,6 +5184,24 @@ packages: transitivePeerDependencies: - supports-color + /@babel/traverse@7.24.6: + resolution: {integrity: sha512-OsNjaJwT9Zn8ozxcfoBc+RaHdj3gFmCmYoQLUII1o6ZrUwku0BMg80FoOTPx+Gi6XhcQxAYE4xyjPTo4SxEQqw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.6 + '@babel/generator': 7.24.6 + '@babel/helper-environment-visitor': 7.24.6 + '@babel/helper-function-name': 7.24.6 + '@babel/helper-hoist-variables': 7.24.6 + '@babel/helper-split-export-declaration': 7.24.6 + '@babel/parser': 7.24.6 + '@babel/types': 7.24.6 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types@7.19.4: resolution: {integrity: sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw==} engines: {node: '>=6.9.0'} @@ -4948,6 +5236,15 @@ packages: '@babel/helper-validator-identifier': 7.22.20 to-fast-properties: 2.0.0 + /@babel/types@7.24.6: + resolution: {integrity: sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.6 + '@babel/helper-validator-identifier': 7.24.6 + to-fast-properties: 2.0.0 + dev: true + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -5287,13 +5584,7 @@ packages: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} dev: true - /@gnu-taler/pogen@0.0.5: - resolution: {integrity: sha512-hd+05sHcYySMY3DUKKxw1eyboWhwQpPr0puGqdsepqXfjAwPyyFzVzF1fnPFc5w/jbn5Wm8ByCB2jEiX24fOqg==} - dependencies: - '@types/node': 14.18.63 - dev: true - - /@headlessui/react@1.7.14(react-dom@18.2.0)(react@18.2.0): + /@headlessui/react@1.7.14(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-znzdq9PG8rkwcu9oQ2FwIy0ZFtP9Z7ycS+BAqJ3R5EIqC/0bJGvhT7193rFf+45i9nnPsYvCQVW4V/bB9Xc+gA==} engines: {node: '>=10'} peerDependencies: @@ -5301,16 +5592,16 @@ packages: react-dom: ^16 || ^17 || ^18 dependencies: client-only: 0.0.1 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@heroicons/react@2.0.17(react@18.2.0): + /@heroicons/react@2.0.17(react@18.3.1): resolution: {integrity: sha512-90GMZktkA53YbNzHp6asVEDevUQCMtxWH+2UK2S8OpnLEu7qckTJPhNxNQG52xIR1WFTwFqtH6bt7a60ZNcLLA==} peerDependencies: react: '>= 16' dependencies: - react: 18.2.0 + react: 18.3.1 /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} @@ -5409,14 +5700,33 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.19 + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + /@jridgewell/resolve-uri@3.1.1: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + dev: true + /@jridgewell/set-array@1.1.2: resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} engines: {node: '>=6.0.0'} + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + dev: true + /@jridgewell/source-map@0.3.2: resolution: {integrity: sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==} dependencies: @@ -5424,11 +5734,11 @@ packages: '@jridgewell/trace-mapping': 0.3.19 dev: true - /@jridgewell/source-map@0.3.5: - resolution: {integrity: sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==} + /@jridgewell/source-map@0.3.6: + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} dependencies: - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.20 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 dev: true /@jridgewell/sourcemap-codec@1.4.15: @@ -5447,6 +5757,13 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + /@jridgewell/trace-mapping@0.3.9: resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: @@ -5601,7 +5918,7 @@ packages: - supports-color dev: true - /@linaria/react@3.0.0-beta.22(react@18.2.0): + /@linaria/react@3.0.0-beta.22(react@18.3.1): resolution: {integrity: sha512-14rnb/zkzhFhJM3hbBOzLLS0bu01mOg23Rv2nGQUt5CWd+HOhksmqzqBtC/ijeVlY2hRI0rJJcng9r07LGGAPA==} engines: {node: ^12.16.0 || >=13.7.0} peerDependencies: @@ -5609,13 +5926,13 @@ packages: dependencies: '@emotion/is-prop-valid': 0.8.8 '@linaria/core': 3.0.0-beta.22 - react: 18.2.0 + react: 18.3.1 ts-invariant: 0.10.3 transitivePeerDependencies: - supports-color dev: true - /@linaria/react@5.0.3(react@18.2.0): + /@linaria/react@5.0.3(react@18.3.1): resolution: {integrity: sha512-faTQHnUlrAz0Lodu+rr6Yx59rX0nqFOrZ5/IdlfQcTRz9VebyVL4vtA3AOecmn1YFZjMpqjopT0OzNz6GknQSg==} engines: {node: '>=16.0.0'} peerDependencies: @@ -5626,7 +5943,7 @@ packages: '@linaria/tags': 5.0.2 '@linaria/utils': 5.0.2 minimatch: 9.0.3 - react: 18.2.0 + react: 18.3.1 react-html-attributes: 1.4.6 ts-invariant: 0.10.3 transitivePeerDependencies: @@ -5721,18 +6038,18 @@ packages: - supports-color dev: true - /@linaria/webpack-loader@3.0.0-beta.22(webpack@4.47.0): + /@linaria/webpack-loader@3.0.0-beta.22(webpack@5.91.0): resolution: {integrity: sha512-oSChk+9MfcoF1M3Thx++aB1IjAaq7gS643i4995GSm1fs53i6QeUpCvIlWClDtRADmBzHSdMKIt0/vLoESvBoQ==} engines: {node: ^12.16.0 || >=13.7.0} dependencies: - '@linaria/webpack4-loader': 3.0.0-beta.23(webpack@4.47.0) - '@linaria/webpack5-loader': 3.0.0-beta.23(webpack@4.47.0) + '@linaria/webpack4-loader': 3.0.0-beta.23(webpack@5.91.0) + '@linaria/webpack5-loader': 3.0.0-beta.23(webpack@5.91.0) transitivePeerDependencies: - supports-color - webpack dev: true - /@linaria/webpack4-loader@3.0.0-beta.23(webpack@4.47.0): + /@linaria/webpack4-loader@3.0.0-beta.23(webpack@5.91.0): resolution: {integrity: sha512-I1pwrRKpGCARWbPwTFqOKLrkyxrZ+huYC3WH4pMllfoY+fv3O2dmDH6vKrZ582mQ5Uo/H3FmHBt8CLaMBv3pmg==} engines: {node: ^12.16.0 || >=13.7.0} peerDependencies: @@ -5743,12 +6060,12 @@ packages: enhanced-resolve: 4.5.0 loader-utils: 1.4.0 mkdirp: 0.5.6 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) transitivePeerDependencies: - supports-color dev: true - /@linaria/webpack5-loader@3.0.0-beta.23(webpack@4.47.0): + /@linaria/webpack5-loader@3.0.0-beta.23(webpack@5.91.0): resolution: {integrity: sha512-yIjhnDT1otwfx6JAA9HNfDzim7N93z9++8apzXE57GXg5wRO2hlajruatclpUDcMOsodS9p2+mMXd8GGR8CGCA==} engines: {node: ^12.16.0 || >=13.7.0} peerDependencies: @@ -5758,7 +6075,7 @@ packages: '@linaria/logger': 3.0.0-beta.20 enhanced-resolve: 5.10.0 mkdirp: 0.5.6 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) transitivePeerDependencies: - supports-color dev: true @@ -6088,10 +6405,28 @@ packages: '@types/node': 20.11.13 dev: true + /@types/eslint-scope@3.7.7: + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + dependencies: + '@types/eslint': 8.56.10 + '@types/estree': 1.0.5 + dev: true + + /@types/eslint@8.56.10: + resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + dev: true + /@types/estree@0.0.39: resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} dev: true + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + /@types/express-serve-static-core@4.17.31: resolution: {integrity: sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==} dependencies: @@ -6194,13 +6529,15 @@ packages: resolution: {integrity: sha512-gFAlWL9Ik21nJioqjlGCnNYbf9zHi0sVbaZ/1hQEBcCEuxfLJDvz4bVJSV6v6CUaoLOz0XEIoP7mSrhJ6o237w==} dev: true - /@types/node@14.18.63: - resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} - dev: true - /@types/node@18.11.17: resolution: {integrity: sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==} + /@types/node@18.19.33: + resolution: {integrity: sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A==} + dependencies: + undici-types: 5.26.5 + dev: true + /@types/node@20.11.13: resolution: {integrity: sha512-5G4zQwdiQBSWYTDAH1ctw2eidqdhMJaNsiIDKHFr55ihz5Trl2qqR8fdrT732yPBho5gkNxXm67OxWFBqX9aPg==} dependencies: @@ -6751,6 +7088,13 @@ packages: - supports-color dev: true + /@webassemblyjs/ast@1.12.1: + resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} + dependencies: + '@webassemblyjs/helper-numbers': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + dev: true + /@webassemblyjs/ast@1.9.0: resolution: {integrity: sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==} dependencies: @@ -6759,14 +7103,26 @@ packages: '@webassemblyjs/wast-parser': 1.9.0 dev: true + /@webassemblyjs/floating-point-hex-parser@1.11.6: + resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} + dev: true + /@webassemblyjs/floating-point-hex-parser@1.9.0: resolution: {integrity: sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==} dev: true + /@webassemblyjs/helper-api-error@1.11.6: + resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} + dev: true + /@webassemblyjs/helper-api-error@1.9.0: resolution: {integrity: sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==} dev: true + /@webassemblyjs/helper-buffer@1.12.1: + resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} + dev: true + /@webassemblyjs/helper-buffer@1.9.0: resolution: {integrity: sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==} dev: true @@ -6787,10 +7143,31 @@ packages: '@webassemblyjs/ast': 1.9.0 dev: true + /@webassemblyjs/helper-numbers@1.11.6: + resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/helper-wasm-bytecode@1.11.6: + resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} + dev: true + /@webassemblyjs/helper-wasm-bytecode@1.9.0: resolution: {integrity: sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==} dev: true + /@webassemblyjs/helper-wasm-section@1.12.1: + resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/wasm-gen': 1.12.1 + dev: true + /@webassemblyjs/helper-wasm-section@1.9.0: resolution: {integrity: sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==} dependencies: @@ -6800,22 +7177,51 @@ packages: '@webassemblyjs/wasm-gen': 1.9.0 dev: true + /@webassemblyjs/ieee754@1.11.6: + resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} + dependencies: + '@xtuc/ieee754': 1.2.0 + dev: true + /@webassemblyjs/ieee754@1.9.0: resolution: {integrity: sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==} dependencies: '@xtuc/ieee754': 1.2.0 dev: true + /@webassemblyjs/leb128@1.11.6: + resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} + dependencies: + '@xtuc/long': 4.2.2 + dev: true + /@webassemblyjs/leb128@1.9.0: resolution: {integrity: sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==} dependencies: '@xtuc/long': 4.2.2 dev: true + /@webassemblyjs/utf8@1.11.6: + resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} + dev: true + /@webassemblyjs/utf8@1.9.0: resolution: {integrity: sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==} dev: true + /@webassemblyjs/wasm-edit@1.12.1: + resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-wasm-section': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-opt': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/wast-printer': 1.12.1 + dev: true + /@webassemblyjs/wasm-edit@1.9.0: resolution: {integrity: sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==} dependencies: @@ -6829,6 +7235,16 @@ packages: '@webassemblyjs/wast-printer': 1.9.0 dev: true + /@webassemblyjs/wasm-gen@1.12.1: + resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + dev: true + /@webassemblyjs/wasm-gen@1.9.0: resolution: {integrity: sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==} dependencies: @@ -6839,6 +7255,15 @@ packages: '@webassemblyjs/utf8': 1.9.0 dev: true + /@webassemblyjs/wasm-opt@1.12.1: + resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + dev: true + /@webassemblyjs/wasm-opt@1.9.0: resolution: {integrity: sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==} dependencies: @@ -6848,6 +7273,17 @@ packages: '@webassemblyjs/wasm-parser': 1.9.0 dev: true + /@webassemblyjs/wasm-parser@1.12.1: + resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + dev: true + /@webassemblyjs/wasm-parser@1.9.0: resolution: {integrity: sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==} dependencies: @@ -6870,6 +7306,13 @@ packages: '@xtuc/long': 4.2.2 dev: true + /@webassemblyjs/wast-printer@1.12.1: + resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@xtuc/long': 4.2.2 + dev: true + /@webassemblyjs/wast-printer@1.9.0: resolution: {integrity: sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==} dependencies: @@ -6916,6 +7359,14 @@ packages: acorn-walk: 6.2.0 dev: true + /acorn-import-assertions@1.9.0(acorn@8.11.3): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.11.3 + dev: true + /acorn-jsx@5.3.2(acorn@7.4.1): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -6972,6 +7423,12 @@ packages: hasBin: true dev: true + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /acorn@8.8.1: resolution: {integrity: sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA==} engines: {node: '>=0.4.0'} @@ -7554,7 +8011,7 @@ packages: postcss-value-parser: 4.2.0 dev: true - /autoprefixer@10.4.14(postcss@8.4.33): + /autoprefixer@10.4.14(postcss@8.4.38): resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==} engines: {node: ^10 || ^12 || >=14} hasBin: true @@ -7566,7 +8023,7 @@ packages: fraction.js: 4.2.0 normalize-range: 0.1.2 picocolors: 1.0.0 - postcss: 8.4.33 + postcss: 8.4.38 postcss-value-parser: 4.2.0 dev: true @@ -7668,7 +8125,7 @@ packages: webpack: 4.46.0 dev: true - /babel-loader@8.2.5(@babel/core@7.18.9)(webpack@4.47.0): + /babel-loader@8.2.5(@babel/core@7.18.9)(webpack@5.91.0): resolution: {integrity: sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==} engines: {node: '>= 8.9'} peerDependencies: @@ -7680,7 +8137,7 @@ packages: loader-utils: 2.0.3 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) dev: true /babel-loader@8.2.5(@babel/core@7.22.1)(webpack@4.46.0): @@ -7832,16 +8289,16 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - /base64-inline-loader@1.1.1(webpack@4.47.0): + /base64-inline-loader@1.1.1(webpack@5.91.0): resolution: {integrity: sha512-v/bHvXQ8sW28t9ZwBsFGgyqZw2bpT49/dtPTtlmixoSM/s9wnOngOKFVQLRH8BfMTy6jTl5m5CdvqpZt8y5d6g==} engines: {node: '>=6.2', npm: '>=3.8'} peerDependencies: webpack: ^4.x dependencies: - file-loader: 1.1.11(webpack@4.47.0) + file-loader: 1.1.11(webpack@5.91.0) loader-utils: 1.4.0 mime-types: 2.1.35 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) dev: true /base64-js@1.5.1: @@ -7871,12 +8328,12 @@ packages: tweetnacl: 0.14.5 dev: true - /better-sqlite3@9.4.0: - resolution: {integrity: sha512-5kynxekMxSjCMiFyUBLHggFcJkCmiZi6fUkiGz/B5GZOvdRWQJD0klqCx5/Y+bm2AKP7I/DHbSFx26AxjruWNg==} + /better-sqlite3@10.0.0: + resolution: {integrity: sha512-rOz0JY8bt9oMgrFssP7GnvA5R3yln73y/NizzWqy3WlFth8Ux8+g4r/N9fjX97nn4X1YX6MTER2doNpTu5pqiA==} requiresBuild: true dependencies: bindings: 1.5.0 - prebuild-install: 7.1.1 + prebuild-install: 7.1.2 dev: false optional: true @@ -8127,6 +8584,17 @@ packages: node-releases: 2.0.14 update-browserslist-db: 1.0.13(browserslist@4.22.2) + /browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001623 + electron-to-chromium: 1.4.783 + node-releases: 2.0.14 + update-browserslist-db: 1.0.16(browserslist@4.23.0) + dev: true + /buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} dev: true @@ -8446,6 +8914,10 @@ packages: /caniuse-lite@1.0.30001570: resolution: {integrity: sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw==} + /caniuse-lite@1.0.30001623: + resolution: {integrity: sha512-X/XhAVKlpIxWPpgRTnlgZssJrF0m6YtRA0QDWgsBNT12uZM6LPRydR7ip405Y3t1LamD8cP2TZFEDZFBf5ApcA==} + dev: true + /caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} dev: true @@ -8582,6 +9054,23 @@ packages: optionalDependencies: fsevents: 2.3.3 + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + requiresBuild: true + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + optional: true + /chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} requiresBuild: true @@ -9805,6 +10294,14 @@ packages: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} requiresBuild: true + dev: true + + /detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + requiresBuild: true + dev: false + optional: true /detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -10053,6 +10550,10 @@ packages: /electron-to-chromium@1.4.613: resolution: {integrity: sha512-r4x5+FowKG6q+/Wj0W9nidx7QO31BJwmR2uEo+Qh3YLGQ8SbBAFuDFpTxzly/I2gsbrFwBuIjrMp423L3O5U3w==} + /electron-to-chromium@1.4.783: + resolution: {integrity: sha512-bT0jEz/Xz1fahQpbZ1D7LgmPYZ3iHVY39NcWWro1+hA2IvjiPeaXtfSqrQ+nXjApMvQRE2ASt1itSLRrebHMRQ==} + dev: true + /elliptic@6.5.4: resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} dependencies: @@ -10124,6 +10625,14 @@ packages: tapable: 2.2.1 dev: true + /enhanced-resolve@5.16.1: + resolution: {integrity: sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + /enquirer@2.3.6: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} engines: {node: '>=8.6'} @@ -10226,6 +10735,10 @@ packages: safe-array-concat: 1.0.1 dev: true + /es-module-lexer@1.5.3: + resolution: {integrity: sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg==} + dev: true + /es-set-tostringtag@2.0.2: resolution: {integrity: sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==} engines: {node: '>= 0.4'} @@ -10299,6 +10812,11 @@ packages: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} + /escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + dev: true + /escape-goat@2.1.1: resolution: {integrity: sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==} engines: {node: '>=8'} @@ -11220,7 +11738,7 @@ packages: flat-cache: 3.0.4 dev: true - /file-loader@1.1.11(webpack@4.47.0): + /file-loader@1.1.11(webpack@5.91.0): resolution: {integrity: sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==} engines: {node: '>= 4.3 < 5.0.0 || >= 5.10'} peerDependencies: @@ -11228,7 +11746,7 @@ packages: dependencies: loader-utils: 1.4.0 schema-utils: 0.4.7 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) dev: true /file-loader@6.2.0(webpack@4.46.0): @@ -11581,7 +12099,7 @@ packages: requiresBuild: true dependencies: bindings: 1.5.0 - nan: 2.18.0 + nan: 2.19.0 dev: true optional: true @@ -12221,7 +12739,7 @@ packages: he: 1.2.0 param-case: 3.0.4 relateurl: 0.2.7 - terser: 5.26.0 + terser: 5.31.0 dev: true /html-minifier@3.5.21: @@ -12273,29 +12791,35 @@ packages: webpack: 4.46.0 dev: true - /html-webpack-plugin@5.5.4(webpack@4.47.0): - resolution: {integrity: sha512-3wNSaVVxdxcu0jd4FpQFoICdqgxs4zIQQvj+2yQKFfBOnLETQ6X5CDWdeasuGlSsooFlMkEioWDTqBv1wvw5Iw==} + /html-webpack-plugin@5.6.0(webpack@5.91.0): + resolution: {integrity: sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==} engines: {node: '>=10.13.0'} peerDependencies: + '@rspack/core': 0.x || 1.x webpack: ^5.20.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 lodash: 4.17.21 pretty-error: 4.0.0 tapable: 2.2.1 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) dev: true - /html-webpack-skip-assets-plugin@1.0.3(html-webpack-plugin@5.5.4)(webpack@4.47.0): + /html-webpack-skip-assets-plugin@1.0.3(html-webpack-plugin@5.6.0)(webpack@5.91.0): resolution: {integrity: sha512-vpdh+JZGlE1Df3IftH2gw5P7b6yfTsahcOIJnkkkj5iJU9dUStXgzgALoXWwl8+17wWgFm3edcJzeYTJBYfMAw==} peerDependencies: html-webpack-plugin: '>=3.0.0' webpack: '>=3.0.0' dependencies: - html-webpack-plugin: 5.5.4(webpack@4.47.0) + html-webpack-plugin: 5.6.0(webpack@5.91.0) minimatch: 3.0.4 - webpack: 4.47.0 + webpack: 5.91.0(esbuild@0.19.9) dev: true /htmlparser2@6.1.0: @@ -13224,6 +13748,15 @@ packages: supports-color: 7.2.0 dev: true + /jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/node': 18.19.33 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + /jiti@1.18.2: resolution: {integrity: sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==} hasBin: true @@ -13598,6 +14131,11 @@ packages: engines: {node: '>=4.3.0 <5.0.0 || >=5.10'} dev: true + /loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + dev: true + /loader-utils@0.2.17: resolution: {integrity: sha512-tiv66G0SmiOx+pLWMtGEkfSEejxvb6N6uRrQjfWJIT79W9GMpgKeCAmm9aVBKtd4WEgntciI8CsGqjpDoCWJug==} dependencies: @@ -13770,6 +14308,7 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 + dev: true /lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -14321,6 +14860,12 @@ packages: dev: true optional: true + /nan@2.19.0: + resolution: {integrity: sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==} + requiresBuild: true + dev: true + optional: true + /nanoclone@0.2.1: resolution: {integrity: sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==} dev: false @@ -14410,12 +14955,12 @@ packages: tslib: 2.6.2 dev: true - /node-abi@3.52.0: - resolution: {integrity: sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==} + /node-abi@3.62.0: + resolution: {integrity: sha512-CPMcGa+y33xuL1E0TcNIu4YyaZCxnnvkVaEXrsosR3FxN+fV8xvb7Mzpb7IgKler10qeMkE6+Dp8qJhpzdq35g==} engines: {node: '>=10'} requiresBuild: true dependencies: - semver: 7.5.4 + semver: 7.6.2 dev: false optional: true @@ -15237,6 +15782,10 @@ packages: /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + /picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + dev: true + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -16088,6 +16637,15 @@ packages: source-map-js: 1.0.2 dev: true + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.1 + source-map-js: 1.2.0 + dev: true + /preact-cli@3.4.1(eslint@8.56.0)(preact-render-to-string@5.2.6)(preact@10.11.3): resolution: {integrity: sha512-/4be0PuBmAIAox9u8GLJublFpEymq7Lk4JW4PEPz9ErFH/ncZf/oBPhECtXGq9IPqNOEe4r2l8sA+3uqKVwBfw==} engines: {node: '>=12'} @@ -16222,19 +16780,19 @@ packages: /preact@10.11.3: resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} - /prebuild-install@7.1.1: - resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} + /prebuild-install@7.1.2: + resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} engines: {node: '>=10'} hasBin: true requiresBuild: true dependencies: - detect-libc: 2.0.2 + detect-libc: 2.0.3 expand-template: 2.0.3 github-from-package: 0.0.0 minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 1.0.2 - node-abi: 3.52.0 + node-abi: 3.62.0 pump: 3.0.0 rc: 1.2.8 simple-get: 4.0.1 @@ -16566,14 +17124,14 @@ packages: minimist: 1.2.8 strip-json-comments: 2.0.1 - /react-dom@18.2.0(react@18.2.0): - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + /react-dom@18.3.1(react@18.3.1): + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: - react: ^18.2.0 + react: ^18.3.1 dependencies: loose-envify: 1.4.0 - react: 18.2.0 - scheduler: 0.23.0 + react: 18.3.1 + scheduler: 0.23.2 dev: false /react-html-attributes@1.4.6: @@ -16591,8 +17149,8 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + /react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 @@ -17134,8 +17692,8 @@ packages: xmlchars: 2.2.0 dev: true - /scheduler@0.23.0: - resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} dependencies: loose-envify: 1.4.0 dev: false @@ -17175,6 +17733,15 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + dev: true + /schema-utils@4.0.0: resolution: {integrity: sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==} engines: {node: '>= 12.13.0'} @@ -17250,6 +17817,15 @@ packages: requiresBuild: true dependencies: lru-cache: 6.0.0 + dev: true + + /semver@7.6.2: + resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + engines: {node: '>=10'} + hasBin: true + requiresBuild: true + dev: false + optional: true /send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} @@ -17297,6 +17873,12 @@ packages: randombytes: 2.1.0 dev: true + /serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + dependencies: + randombytes: 2.1.0 + dev: true + /serve-index@1.9.1: resolution: {integrity: sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==} engines: {node: '>= 0.8.0'} @@ -17618,6 +18200,11 @@ packages: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + dev: true + /source-map-resolve@0.5.3: resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} deprecated: See https://github.com/lydell/source-map-resolve#deprecated @@ -18127,23 +18714,23 @@ packages: stable: 0.1.8 dev: true - /swr@2.0.3(react@18.2.0): + /swr@2.0.3(react@18.3.1): resolution: {integrity: sha512-sGvQDok/AHEWTPfhUWXEHBVEXmgGnuahyhmRQbjl9XBYxT/MSlAzvXEKQpyM++bMPaI52vcWS2HiKNaW7+9OFw==} engines: {pnpm: '7'} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 dependencies: - react: 18.2.0 - use-sync-external-store: 1.2.0(react@18.2.0) + react: 18.3.1 + use-sync-external-store: 1.2.0(react@18.3.1) - /swr@2.2.2(react@18.2.0): + /swr@2.2.2(react@18.3.1): resolution: {integrity: sha512-CbR41AoMD4TQBQw9ic3GTXspgfM9Y8Mdhb5Ob4uIKXhWqnRLItwA5fpGvB7SmSw3+zEjb0PdhiEumtUvYoQ+bQ==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 dependencies: client-only: 0.0.1 - react: 18.2.0 - use-sync-external-store: 1.2.0(react@18.2.0) + react: 18.3.1 + use-sync-external-store: 1.2.0(react@18.3.1) dev: false /symbol-tree@3.2.4: @@ -18301,24 +18888,6 @@ packages: worker-farm: 1.7.0 dev: true - /terser-webpack-plugin@1.4.5(webpack@4.47.0): - resolution: {integrity: sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==} - engines: {node: '>= 6.9.0'} - peerDependencies: - webpack: ^4.0.0 - dependencies: - cacache: 12.0.4 - find-cache-dir: 2.1.0 - is-wsl: 1.1.0 - schema-utils: 1.0.0 - serialize-javascript: 4.0.0 - source-map: 0.6.1 - terser: 4.8.1 - webpack: 4.47.0 - webpack-sources: 1.4.3 - worker-farm: 1.7.0 - dev: true - /terser-webpack-plugin@4.2.3(webpack@4.46.0): resolution: {integrity: sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==} engines: {node: '>= 10.13.0'} @@ -18339,6 +18908,31 @@ packages: - bluebird dev: true + /terser-webpack-plugin@5.3.10(esbuild@0.19.9)(webpack@5.91.0): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + esbuild: 0.19.9 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.31.0 + webpack: 5.91.0(esbuild@0.19.9) + dev: true + /terser@4.8.1: resolution: {integrity: sha512-4GnLC0x667eJG0ewJTa6z/yXrbLGv80D9Ru6HIpCQmO+Q4PfEtBFi0ObSckqwL6VyQv/7ENJieXHo2ANmdQwgw==} engines: {node: '>=6.0.0'} @@ -18360,13 +18954,13 @@ packages: source-map-support: 0.5.21 dev: true - /terser@5.26.0: - resolution: {integrity: sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==} + /terser@5.31.0: + resolution: {integrity: sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==} engines: {node: '>=10'} hasBin: true dependencies: - '@jridgewell/source-map': 0.3.5 - acorn: 8.11.2 + '@jridgewell/source-map': 0.3.6 + acorn: 8.11.3 commander: 2.20.3 source-map-support: 0.5.21 dev: true @@ -18945,6 +19539,17 @@ packages: escalade: 3.1.1 picocolors: 1.0.0 + /update-browserslist-db@1.0.16(browserslist@4.23.0): + resolution: {integrity: sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.23.0 + escalade: 3.1.2 + picocolors: 1.0.1 + dev: true + /update-notifier@5.1.0: resolution: {integrity: sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==} engines: {node: '>=10'} @@ -19031,12 +19636,12 @@ packages: qs: 6.11.2 dev: true - /use-sync-external-store@1.2.0(react@18.2.0): + /use-sync-external-store@1.2.0(react@18.3.1): resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - react: 18.2.0 + react: 18.3.1 /use@3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} @@ -19182,7 +19787,7 @@ packages: graceful-fs: 4.2.11 neo-async: 2.6.2 optionalDependencies: - chokidar: 3.5.3 + chokidar: 3.6.0 watchpack-chokidar2: 2.0.1 transitivePeerDependencies: - supports-color @@ -19196,6 +19801,14 @@ packages: graceful-fs: 4.2.11 dev: true + /watchpack@2.4.1: + resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} + engines: {node: '>=10.13.0'} + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + dev: true + /wbuf@1.7.3: resolution: {integrity: sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==} dependencies: @@ -19403,6 +20016,11 @@ packages: source-map: 0.6.1 dev: true + /webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + dev: true + /webpack@4.46.0: resolution: {integrity: sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q==} engines: {node: '>=6.11.5'} @@ -19443,44 +20061,44 @@ packages: - supports-color dev: true - /webpack@4.47.0: - resolution: {integrity: sha512-td7fYwgLSrky3fI1EuU5cneU4+pbH6GgOfuKNS1tNPcfdGinGELAqsb/BP4nnvZyKSG2i/xFGU7+n2PvZA8HJQ==} - engines: {node: '>=6.11.5'} + /webpack@5.91.0(esbuild@0.19.9): + resolution: {integrity: sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==} + engines: {node: '>=10.13.0'} hasBin: true peerDependencies: webpack-cli: '*' - webpack-command: '*' peerDependenciesMeta: webpack-cli: optional: true - webpack-command: - optional: true dependencies: - '@webassemblyjs/ast': 1.9.0 - '@webassemblyjs/helper-module-context': 1.9.0 - '@webassemblyjs/wasm-edit': 1.9.0 - '@webassemblyjs/wasm-parser': 1.9.0 - acorn: 6.4.2 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.23.0 chrome-trace-event: 1.0.3 - enhanced-resolve: 4.5.0 - eslint-scope: 4.0.3 - json-parse-better-errors: 1.0.2 - loader-runner: 2.4.0 - loader-utils: 1.4.2 - memory-fs: 0.4.1 - micromatch: 3.1.10 - mkdirp: 0.5.6 + enhanced-resolve: 5.16.1 + es-module-lexer: 1.5.3 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 neo-async: 2.6.2 - node-libs-browser: 2.2.1 - schema-utils: 1.0.0 - tapable: 1.1.3 - terser-webpack-plugin: 1.4.5(webpack@4.47.0) - watchpack: 1.7.5 - webpack-sources: 1.4.3 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(esbuild@0.19.9)(webpack@5.91.0) + watchpack: 2.4.1 + webpack-sources: 3.2.3 transitivePeerDependencies: - - supports-color + - '@swc/core' + - esbuild + - uglify-js dev: true /websocket-driver@0.7.4: @@ -19995,6 +20613,7 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}