commit f75859dd6141f543546c8f117162780a1e635614 parent a3308d5fb99beb2e036e4d6ae5a9d6238a990449 Author: Sebastian <sebasjm@gmail.com> Date: Wed, 1 Oct 2025 10:18:42 -0300 fixes #8526 Diffstat:
22 files changed, 1020 insertions(+), 418 deletions(-)
diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -17,24 +17,20 @@ import { AbsoluteTime, assertUnreachable, BitcoinBech32, - buildPayto, encodeCrock, getURLHostnamePortPath, - hashNormalizedPaytoUri, HostPortPath, HttpStatusCode, IbanString, OperationOk, parseIban, ParseIbanError, - parsePaytoUri, PaytoParseError, Paytos, - PaytoUri, + PaytoType, ReservePubParseError, - stringifyPaytoUri, TalerError, - TranslatedString, + TranslatedString } from "@gnu-taler/taler-util"; import { Attention, @@ -378,7 +374,7 @@ function IbanForm({ const paytoUri = form.status.status === "fail" ? undefined - : Paytos.createIbanPayto( + : Paytos.createIban( form.status.result.account as IbanString, undefined, {}, @@ -616,28 +612,28 @@ function translatePaytoError( return i18n.str`The target type is not supported, only x-taler-bank, taler-reserve or iban`; case PaytoParseError.COMPONENTS_LENGTH: { switch (result.body.targetType) { - case "iban": + case PaytoType.IBAN: return i18n.str`IBAN is missing`; - case "bitcoin": + case PaytoType.Bitcoin: return i18n.str`Bitcoin address is missing`; - case "x-taler-bank": + case PaytoType.TalerBank: return i18n.str`Bank host and account is missing`; - case "taler-reserve": + case PaytoType.TalerReserve: return i18n.str`Exchange host and account is missing`; - case "taler-reserve-http": + case PaytoType.TalerReserveHttp: return i18n.str`Exchange host and account is missing`; - case "ethereum": + case PaytoType.Ethereum: return i18n.str`Ethereum address is missing`; } } case PaytoParseError.INVALID_TARGET_PATH: { switch (result.body.targetType) { - case "iban": + case PaytoType.IBAN: return i18n.str`Invalid IBAN: ${translateIbanError( result.body.error, i18n, )}`; - case "bitcoin": { + case PaytoType.Bitcoin: { switch (result.body.pos) { case 0: return i18n.str`Invalid BTC: ${translateBitcoinError( @@ -653,7 +649,7 @@ function translatePaytoError( assertUnreachable(result.body); } } - case "x-taler-bank": { + case PaytoType.TalerBank: { switch (result.body.pos) { case 0: return i18n.str`Invalid host`; @@ -663,7 +659,7 @@ function translatePaytoError( assertUnreachable(result.body); } } - case "taler-reserve": { + case PaytoType.TalerReserve: { switch (result.body.pos) { case 0: return i18n.str`Invalid host`; @@ -676,7 +672,7 @@ function translatePaytoError( assertUnreachable(result.body); } } - case "taler-reserve-http": { + case PaytoType.TalerReserveHttp: { switch (result.body.pos) { case 0: return i18n.str`Invalid host`; @@ -689,7 +685,7 @@ function translatePaytoError( assertUnreachable(result.body); } } - case "ethereum": { + case PaytoType.Ethereum: { switch (result.body.pos) { case 0: return i18n.str`Invalid address`; diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts @@ -33,6 +33,7 @@ import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { useSessionState } from "../../hooks/session.js"; import { Props, State } from "./index.js"; +import { HostPortPath } from "@gnu-taler/taler-util"; export function useComponentState({ routeClose, @@ -129,7 +130,7 @@ export function useComponentState({ // } const uri = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href, + bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href as HostPortPath, withdrawalOperationId, }); const parsedUri = parseWithdrawUri(uri); diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -20,14 +20,16 @@ import { Amounts, CurrencySpecification, FRAC_SEPARATOR, + HostPortPath, HttpStatusCode, + IbanString, PaytoString, PaytoUri, + Paytos, TalerCorebankApi, TalerErrorCode, TranslatedString, assertUnreachable, - buildPayto, parsePaytoUri, stringifyPaytoUri, } from "@gnu-taler/taler-util"; @@ -42,7 +44,7 @@ import { useBankCoreApiContext, useChallengeHandler, useLocalNotificationBetter, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -101,11 +103,13 @@ export function PaytoWireTransferForm({ ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); - - const trimmedAmountStr = amount?.trim(); - const limitWithFee = IntAmounts.from(limit).deduce(wireFee).getResultZeroIfNegative() - const parsedAmount = Amounts.parse(`${limitWithFee.currency}:${trimmedAmountStr}`); + const limitWithFee = IntAmounts.from(limit) + .deduce(wireFee) + .getResultZeroIfNegative(); + const parsedAmount = Amounts.parse( + `${limitWithFee.currency}:${trimmedAmountStr}`, + ); const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); @@ -174,12 +178,11 @@ export function PaytoWireTransferForm({ acName = account; switch (paytoType) { case "x-taler-bank": { - payto = buildPayto("x-taler-bank", url.host, account); - + payto = Paytos.createTalerBank(url.host as HostPortPath, account); break; } case "iban": { - payto = buildPayto("iban", account, undefined); + payto = Paytos.createIban(account as IbanString, undefined); break; } default: @@ -187,8 +190,9 @@ export function PaytoWireTransferForm({ } payto.params.message = encodeURIComponent(subject); - payto_uri = stringifyPaytoUri(payto); - sendingAmount = `${limitWithFee.currency}:${trimmedAmountStr}` as AmountString; + payto_uri = Paytos.toFullString(payto); + sendingAmount = + `${limitWithFee.currency}:${trimmedAmountStr}` as AmountString; } const puri = payto_uri; const sAmount = sendingAmount; @@ -347,12 +351,11 @@ export function PaytoWireTransferForm({ name="input-type" onChange={() => { if (account) { - let payto; + let payto: Paytos.URI; switch (paytoType) { case "x-taler-bank": { - payto = buildPayto( - "x-taler-bank", - url.host, + payto = Paytos.createTalerBank( + url.host as HostPortPath, account, ); if (parsedAmount) { @@ -365,7 +368,10 @@ export function PaytoWireTransferForm({ break; } case "iban": { - payto = buildPayto("iban", account, undefined); + payto = Paytos.createIban( + account as IbanString, + undefined, + ); if (parsedAmount) { payto.params["amount"] = Amounts.stringify(parsedAmount); @@ -378,7 +384,7 @@ export function PaytoWireTransferForm({ default: assertUnreachable(paytoType); } - rawPaytoInputSetter(stringifyPaytoUri(payto)); + rawPaytoInputSetter(Paytos.toFullString(payto)); } setInputType("payto"); }} diff --git a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx @@ -21,6 +21,7 @@ import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; import { useBankState } from "../hooks/bank-state.js"; import { RouteDefinition } from "@gnu-taler/web-util/browser"; import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; +import { HostPortPath } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 115; @@ -42,7 +43,7 @@ export function WithdrawalOperationPage({ lib: { bank: api }, } = useBankCoreApiContext(); const uri = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl: api.getIntegrationAPI().href, + bankIntegrationApiBaseUrl: api.getIntegrationAPI().href as HostPortPath, withdrawalOperationId: operationId, }); const parsedUri = parseWithdrawUri(uri); diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -19,7 +19,6 @@ import { PaytoString, TalerCorebankApi, assertUnreachable, - buildPayto, parsePaytoUri, stringifyPaytoUri, } from "@gnu-taler/taler-util"; @@ -45,6 +44,9 @@ import { doAutoFocus, } from "../PaytoWireTransferForm.js"; import { getRandomPassword } from "../rnd.js"; +import { Paytos } from "@gnu-taler/taler-util"; +import { HostPortPath } from "@gnu-taler/taler-util"; +import { IbanString } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 120; @@ -222,40 +224,39 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ if (errors) { onChange(undefined); } else { - let cashout; + let cashout: Paytos.URI | undefined; if (newForm.cashout_payto_uri) switch (cashoutPaytoType) { case "x-taler-bank": { - cashout = buildPayto( - "x-taler-bank", - url.host, + cashout = Paytos.createTalerBank( + url.host as HostPortPath, newForm.cashout_payto_uri, ); break; } case "iban": { - cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined); + cashout = Paytos.createIban(newForm.cashout_payto_uri as IbanString, undefined); break; } default: assertUnreachable(cashoutPaytoType); } - const cashoutURI = !cashout ? null : stringifyPaytoUri(cashout); - let internal; + const cashoutURI = !cashout ? null : Paytos.toFullString(cashout); + let internal: Paytos.URI | undefined; if (newForm.payto_uri) switch (paytoType) { case "x-taler-bank": { - internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri); + internal = Paytos.createTalerBank(url.host as HostPortPath, newForm.payto_uri); break; } case "iban": { - internal = buildPayto("iban", newForm.payto_uri, undefined); + internal = Paytos.createIban(newForm.payto_uri as IbanString, undefined); break; } default: assertUnreachable(paytoType); } - const internalURI = !internal ? undefined : stringifyPaytoUri(internal); + const internalURI = !internal ? undefined : Paytos.toFullString(internal); const threshold = !parsedDebitThreshold ? undefined diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -22,6 +22,7 @@ import { AmountJson, Amounts, + HostPortPath, MerchantContractVersion, TalerMerchantApi, stringifyRefundUri, @@ -445,7 +446,7 @@ function PaidPage({ const { state } = useSessionContext(); const refundurl = stringifyRefundUri({ - merchantBaseUrl: state.backendUrl.href, + merchantBaseUrl: state.backendUrl.href as HostPortPath, orderId: order.contract_terms.order_id, }); const { i18n } = useTranslationContext(); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx @@ -20,6 +20,7 @@ */ import { + HostPortPath, TalerMerchantApi, stringifyPayTemplateUri, } from "@gnu-taler/taler-util"; @@ -40,7 +41,7 @@ export function QrPage({ id: templateId, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const { state } = useSessionContext(); - const merchantBaseUrl = state.backendUrl.href; + const merchantBaseUrl = state.backendUrl.href as HostPortPath; const payTemplateUri = stringifyPayTemplateUri({ merchantBaseUrl, diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -27,6 +27,7 @@ import { DonauHttpClient, Duration, EddsaPrivP, + HostPortPath, HttpStatusCode, Logger, LoginTokenScope, @@ -871,7 +872,7 @@ deploymentCli args.provisionBankMerchant.corebankApiBaseUrl, httpLib, ); - const instanceURL = merchantManager.getSubInstanceAPI(id); + const instanceURL = merchantManager.getSubInstanceAPI(id) as HostPortPath; const merchantInstance = new TalerMerchantInstanceHttpClient( instanceURL, httpLib, diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts @@ -30,7 +30,12 @@ import { codecForString, renderContext, } from "./codec.js"; -import { CurrencySpecification } from "./index.js"; +import { + CurrencySpecification, + OperationResult, + opFixedSuccess, + opKnownFailure, +} from "./index.js"; import { AmountString } from "./types-taler-common.js"; /** @@ -56,6 +61,11 @@ export const amountMaxValue = 2 ** 52; export const FRAC_SEPARATOR = "."; /** + * Separator character between integer and fractional + */ +export const CURRENCY_SEPARATOR = ":"; + +/** * Non-negative financial amount. Fractional values are expressed as multiples * of 1e-8. */ @@ -179,6 +189,33 @@ export interface DivmodResult { remainder: AmountJson; } +export enum AmountParseError { + /** + * Should have a string separated with a colon + */ + MISSING_CURRENCY, + /** + * Currency should be less than 11 characters + */ + CURRENCY_TOO_LONG, + /** + * Currency shoul be only letter from a to z + */ + BAD_CURRENCY, + /** + * Number can only be digits from 0 to 9 + */ + BAD_NUMBER, + /** + * Integer part should be less than 2 ** 52 + */ + TOO_HIGH, + /** + * Fractional part should be shorter than 8 digits + */ + TOO_PRECISE, +} + /** * Helper class for dealing with amounts. */ @@ -438,6 +475,57 @@ export class Amounts { * Currency name size limit is 11 of ASCII letters * Fraction size limit is 8 */ + static parseWithError(s: string) { + const c_idx = s.indexOf(CURRENCY_SEPARATOR); + + if (c_idx === -1 || c_idx === 0) { + return opKnownFailure(AmountParseError.MISSING_CURRENCY); + } + if (c_idx > 11) { + return opKnownFailure(AmountParseError.MISSING_CURRENCY); + } + const currency = s.substring(0, c_idx).toUpperCase(); + if (!/^[a-zA-Z]+$/.test(currency)) { + return opKnownFailure(AmountParseError.BAD_CURRENCY); + } + const number = s.substring(c_idx + 1); + const d_idx = number.indexOf(FRAC_SEPARATOR); + const integerStr = number.substring(0, d_idx); + const fractStr = + d_idx === -1 || d_idx === number.length + ? "0" + : number.substring(d_idx + 1); + + if (!/^[0-9]+$/.test(integerStr) || !/^[0-9]+$/.test(fractStr)) { + return opKnownFailure(AmountParseError.BAD_NUMBER); + } + + const value = Number.parseInt(integerStr, 10); + const fraction = Math.round( + amountFractionalBase * Number.parseFloat(FRAC_SEPARATOR + fractStr), + ); + if (Number.isInteger(value) || Number.isInteger(fraction)) { + return opKnownFailure(AmountParseError.BAD_NUMBER); + } + if (value > amountMaxValue) { + return opKnownFailure(AmountParseError.TOO_HIGH); + } + if (fractStr.length > amountFractionalLength) { + return opKnownFailure(AmountParseError.TOO_PRECISE); + } + return opFixedSuccess({ + currency, + fraction, + value, + }); + } + + /** + * Parse an amount like 'EUR:20.5' for 20 Euros and 50 ct. + * + * Currency name size limit is 11 of ASCII letters + * Fraction size limit is 8 + */ static parse(s: string): AmountJson | undefined { const res = s.match(/^([a-zA-Z]{1,11}):([0-9]+)([.][0-9]{1,8})?$/); if (!res) { diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -40,13 +40,14 @@ export type PaytoString = string; const PAYTO_PREFIX = "payto://"; -export type PaytoType = - | "iban" - | "bitcoin" - | "x-taler-bank" - | "taler-reserve" - | "taler-reserve-http" - | "ethereum"; +export enum PaytoType { + IBAN = "iban", + Bitcoin = "bitcoin", + TalerBank = "x-taler-bank", + TalerReserve = "taler-reserve", + TalerReserveHttp = "taler-reserve-http", + Ethereum = "ethereum", +} export enum ReservePubParseError { /** @@ -106,7 +107,7 @@ export namespace Paytos { /** * String after the prefix and before the first / */ - targetType: PaytoType | "unsupported"; + targetType: PaytoType | undefined; /** * String after the first / */ @@ -126,43 +127,43 @@ export namespace Paytos { } export interface PaytoUnsupported extends PaytoGeneric { - targetType: "unsupported"; + targetType: undefined; target: string; } export interface PaytoIBAN extends PaytoGeneric { - targetType: "iban"; + targetType: PaytoType.IBAN; iban: IbanString; bic?: string; } export interface PaytoTalerReserve extends PaytoGeneric { - targetType: "taler-reserve"; + targetType: PaytoType.TalerReserve; exchange: HostPortPath; reservePub: Uint8Array; } export interface PaytoTalerReserveHttp extends PaytoGeneric { - targetType: "taler-reserve-http"; + targetType: PaytoType.TalerReserveHttp; exchange: HostPortPath; reservePub: Uint8Array; } export interface PaytoTalerBank extends PaytoGeneric { - targetType: "x-taler-bank"; + targetType: PaytoType.TalerBank; host: HostPortPath; account: string; } export interface PaytoBitcoin extends PaytoGeneric { - targetType: "bitcoin"; + targetType: PaytoType.Bitcoin; address: BtAddrString; reservePub: Uint8Array | undefined; segwitAddrs: Array<BtAddrString>; } export interface PaytoEthereum extends PaytoGeneric { - targetType: "ethereum"; + targetType: PaytoType.Ethereum; address: EthAddrString; } @@ -176,9 +177,9 @@ export namespace Paytos { }; export function hash(p: NormalizedPaytoString | FullPaytoString): Uint8Array { - return hashTruncate32(stringToBytes(p + "\0")) + return hashTruncate32(stringToBytes(p + "\0")); } - + /** * A **normalized** payto-URI uniquely identifies a bank account (or * wallet) and must be able to serve as a canonical representation of such a @@ -225,42 +226,34 @@ export namespace Paytos { }); } } + /** + * Check hostname is a valid string with form $host:$port/$path like + * domain.com:22/some/path + * + * Return the canonical form. + * + * @param hostname + * @param scheme + * @returns + */ export function parseHostPortPath( hostname: string, scheme: "http" | "https" = "https", ): HostPortPath | undefined { + // maybe it should check that it doesn't contain search or hash? try { const url = new URL("/", `${scheme}://${hostname}`); - if (url.port) { - return `${url.host}:${url.port}` as HostPortPath; - } else { - return `${url.host}` as HostPortPath; + if (!url.pathname.endsWith("/")) { + url.pathname = url.pathname + "/"; } + url.search = ""; + url.password = ""; + url.username = ""; + url.hash = ""; + return url.href as HostPortPath; } catch (e) { return undefined; } - // const res = basicURLParse(hostname, { - // url: "/", - // stateOverride: "host", - // }); - // if (!res) return undefined; - // const port = typeof res.port === "number" ? res.port : undefined; - // if (typeof res.host === "string" || typeof res.host === "number") { - // if (port) { - // return `${res.host}:${port}` as HostAndPort; - // } else { - // return `${res.host}` as HostAndPort; - // } - // } - // if (Array.isArray(res.host)) { - // const h = res.host.join("."); - // if (port) { - // return `${h}:${port}` as HostAndPort; - // } else { - // return `${h}` as HostAndPort; - // } - // } - return undefined; } /** * FIXME: add ethereum address validator @@ -294,7 +287,7 @@ export namespace Paytos { params: Record<string, string> = {}, ): PaytoUnsupported { return { - targetType: "unsupported", + targetType: undefined, target: targetType, params, normalizedPath: path.toLocaleLowerCase(), @@ -302,13 +295,13 @@ export namespace Paytos { displayName: path, }; } - export function createIbanPayto( + export function createIban( iban: IbanString, bic: string | undefined, params: Record<string, string> = {}, ): PaytoIBAN { return { - targetType: "iban", + targetType: PaytoType.IBAN, iban, bic, params, @@ -317,7 +310,7 @@ export namespace Paytos { displayName: iban, }; } - export function createBitcoinPayto( + export function createBitcoin( address: BtAddrString, reservePub: Uint8Array | undefined, params: Record<string, string> = {}, @@ -328,7 +321,7 @@ export namespace Paytos { const segwitAddrs = !sgRes || sgRes.type === "fail" ? [] : sgRes.body; return { - targetType: "bitcoin", + targetType: PaytoType.Bitcoin, address, reservePub, segwitAddrs, @@ -343,7 +336,7 @@ export namespace Paytos { params: Record<string, string> = {}, ): PaytoEthereum { return { - targetType: "ethereum", + targetType: PaytoType.Ethereum, address, params, normalizedPath: address, @@ -358,7 +351,7 @@ export namespace Paytos { ): PaytoTalerReserve { const pub = encodeCrock(reservePub); return { - targetType: "taler-reserve", + targetType: PaytoType.TalerReserve, exchange, reservePub, params, @@ -374,7 +367,7 @@ export namespace Paytos { ): PaytoTalerReserveHttp { const pub = encodeCrock(reservePub); return { - targetType: "taler-reserve-http", + targetType: PaytoType.TalerReserveHttp, exchange, reservePub, params, @@ -389,7 +382,7 @@ export namespace Paytos { params: Record<string, string> = {}, ): PaytoTalerBank { return { - targetType: "x-taler-bank", + targetType: PaytoType.TalerBank, host, account, params, @@ -406,9 +399,13 @@ export namespace Paytos { export function fromString( s: string, opts: { - // do not check path component format + /** + * do not check path component format + */ ignoreComponentError?: boolean; - // take unknown target types as valid + /** + * take unknown target types as valid + */ allowUnsupported?: boolean; } = {}, ) { @@ -416,7 +413,7 @@ export namespace Paytos { return opKnownFailure(PaytoParseError.WRONG_PREFIX); } - const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?"); + const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?", 2); const firstSlashPos = acct.indexOf("/"); @@ -445,7 +442,7 @@ export namespace Paytos { // get URI components const cs = targetPath.split("/"); switch (targetType) { - case "iban": { + case PaytoType.IBAN: { if (cs.length !== 1 && cs.length !== 2) { return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { targetType, @@ -464,9 +461,9 @@ export namespace Paytos { }); } - return opFixedSuccess<URI>(createIbanPayto(iban as IbanString, bic, params)); + return opFixedSuccess<URI>(createIban(iban as IbanString, bic, params)); } - case "bitcoin": { + case PaytoType.Bitcoin: { if (cs.length !== 1 && cs.length !== 2) { return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { targetType, @@ -496,7 +493,7 @@ export namespace Paytos { } return opFixedSuccess<URI>( - createBitcoinPayto( + createBitcoin( address as BtAddrString, pubRes && pubRes.type === "ok" ? pubRes.body : undefined, params, @@ -504,7 +501,7 @@ export namespace Paytos { ); } - case "x-taler-bank": { + case PaytoType.TalerBank: { if (cs.length < 2) { return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { targetType, @@ -536,7 +533,7 @@ export namespace Paytos { ), ); } - case "taler-reserve": { + case PaytoType.TalerReserve: { if (cs.length < 2) { return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { targetType, @@ -569,7 +566,7 @@ export namespace Paytos { ), ); } - case "taler-reserve-http": { + case PaytoType.TalerReserveHttp: { if (cs.length < 2) { return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { targetType, @@ -601,7 +598,7 @@ export namespace Paytos { ), ); } - case "ethereum": { + case PaytoType.Ethereum: { if (cs.length !== 1) { return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { targetType, @@ -776,146 +773,6 @@ export interface PaytoUriEthereum extends PaytoUriGeneric { } /** - * @deprecated use Paytos namespace - */ -export function buildPayto( - type: "iban", - iban: string, - bic: string | undefined, - params?: Record<string, string>, -): PaytoUriIBAN; -/** - * @deprecated use Paytos namespace - */ -export function buildPayto( - type: "taler-reserve", - exchange: string, - reservePub: string, - params?: Record<string, string>, -): PaytoUriIBAN; -/** - * @deprecated use Paytos namespace - */ -export function buildPayto( - type: "taler-reserve-http", - exchange: string, - reservePub: string, - params?: Record<string, string>, -): PaytoUriIBAN; -/** - * @deprecated use Paytos namespace - */ -export function buildPayto( - type: "bitcoin", - address: string, - reserve: string | undefined, - params?: Record<string, string>, -): PaytoUriBitcoin; -/** - * @deprecated use Paytos namespace - */ -export function buildPayto( - type: "x-taler-bank", - host: string, - account: string, - params?: Record<string, string>, -): PaytoUriTalerBank; -/** - * @deprecated use Paytos namespace - */ -export function buildPayto( - type: PaytoType, - first: string, - second?: string, - params: Record<string, string> = {}, -): PaytoUriGeneric { - switch (type) { - case "bitcoin": { - const uppercased = first.toUpperCase(); - const pubRes = !second ? undefined : Paytos.parseReservePub(second); - const addr = - !pubRes || pubRes.type === "fail" - ? undefined - : generateFakeSegwitAddress(pubRes.body, first); - const segwitAddrs = !addr || addr.type === "fail" ? [] : addr.body; - - const result: PaytoUriBitcoin = { - isKnown: true, - targetType: "bitcoin", - targetPath: first, - address: uppercased, - params, - segwitAddrs, - }; - return result; - } - case "ethereum": { - const uppercased = first.toUpperCase(); - const result: PaytoUriEthereum = { - isKnown: true, - targetType: "ethereum", - targetPath: first, - address: uppercased, - params, - }; - return result; - } - case "iban": { - const uppercased = first.toUpperCase(); - const result: PaytoUriIBAN = { - isKnown: true, - targetType: "iban", - iban: uppercased, - params, - targetPath: !second ? uppercased : `${second}/${uppercased}`, - }; - return result; - } - case "x-taler-bank": { - if (!second) throw Error("missing account for payto://x-taler-bank/"); - const result: PaytoUriTalerBank = { - isKnown: true, - targetType: "x-taler-bank", - host: first, - account: second, - params, - targetPath: `${first}/${second}`, - }; - return result; - } - case "taler-reserve-http": { - if (!second) - throw Error("missing reservePub for payto://taler-reserve-http/"); - const result: PaytoUriTalerHttp = { - isKnown: true, - targetType: "taler-reserve-http", - exchange: first, - reservePub: second, - params, - targetPath: `${first}/${second}`, - }; - return result; - } - case "taler-reserve": { - if (!second) throw Error("missing reservePub for payto://taler-reserve/"); - const result: PaytoUriTaler = { - isKnown: true, - targetType: "taler-reserve", - exchange: first, - reservePub: second, - params, - targetPath: `${first}/${second}`, - }; - return result; - } - default: { - const unknownType: never = type; - throw Error(`unknown payto:// type ${unknownType}`); - } - } -} - -/** * Add query parameters to a payto URI. * * Existing parameters are preserved. diff --git a/packages/taler-util/src/taleruri.test.ts b/packages/taler-util/src/taleruri.test.ts @@ -38,6 +38,7 @@ import { stringifyWithdrawExchange, stringifyWithdrawUri, } from "./taleruri.js"; +import { HostPortPath } from "./payto.js"; /** * 5.1 action: withdraw https://lsd.gnunet.org/lsd0006/#name-action-withdraw @@ -51,7 +52,7 @@ test("taler withdraw uri parsing", (t) => { return; } t.is(r1.withdrawalOperationId, "12345"); - t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/"); + t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/" as HostPortPath); }); test("taler withdraw uri parsing with external confirmation", (t) => { @@ -63,7 +64,7 @@ test("taler withdraw uri parsing with external confirmation", (t) => { } t.is(r1.externalConfirmation, true); t.is(r1.withdrawalOperationId, "12345"); - t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/"); + t.is(r1.bankIntegrationApiBaseUrl, "https://bank.example.com/" as HostPortPath); }); test("taler withdraw uri parsing (http)", (t) => { @@ -74,12 +75,12 @@ test("taler withdraw uri parsing (http)", (t) => { return; } t.is(r1.withdrawalOperationId, "12345"); - t.is(r1.bankIntegrationApiBaseUrl, "http://bank.example.com/"); + t.is(r1.bankIntegrationApiBaseUrl, "http://bank.example.com/" as HostPortPath); }); test("taler withdraw URI (stringify)", (t) => { const url = stringifyWithdrawUri({ - bankIntegrationApiBaseUrl: "https://bank.taler.test/integration-api/", + bankIntegrationApiBaseUrl: "https://bank.taler.test/integration-api/" as HostPortPath, withdrawalOperationId: "123", }); t.deepEqual(url, "taler://withdraw/bank.taler.test/integration-api/123"); @@ -95,7 +96,7 @@ test("taler pay url parsing: defaults", (t) => { t.fail(); return; } - t.is(r1.merchantBaseUrl, "https://example.com/"); + t.is(r1.merchantBaseUrl, "https://example.com/" as HostPortPath); t.is(r1.sessionId, ""); const url2 = "taler://pay/example.com/myorder/mysession"; @@ -104,7 +105,7 @@ test("taler pay url parsing: defaults", (t) => { t.fail(); return; } - t.is(r2.merchantBaseUrl, "https://example.com/"); + t.is(r2.merchantBaseUrl, "https://example.com/" as HostPortPath); t.is(r2.sessionId, "mysession"); }); @@ -115,7 +116,7 @@ test("taler pay url parsing: instance", (t) => { t.fail(); return; } - t.is(r1.merchantBaseUrl, "https://example.com/instances/myinst/"); + t.is(r1.merchantBaseUrl, "https://example.com/instances/myinst/" as HostPortPath); t.is(r1.orderId, "myorder"); }); @@ -126,7 +127,7 @@ test("taler pay url parsing (claim token)", (t) => { t.fail(); return; } - t.is(r1.merchantBaseUrl, "https://example.com/instances/myinst/"); + t.is(r1.merchantBaseUrl, "https://example.com/instances/myinst/" as HostPortPath); t.is(r1.orderId, "myorder"); t.is(r1.claimToken, "ASDF"); }); @@ -138,7 +139,7 @@ test("taler pay uri parsing: non-https", (t) => { t.fail(); return; } - t.is(r1.merchantBaseUrl, "http://example.com/"); + t.is(r1.merchantBaseUrl, "http://example.com/" as HostPortPath); t.is(r1.orderId, "myorder"); }); @@ -154,14 +155,14 @@ test("taler pay uri parsing: missing session component", (t) => { test("taler pay URI (stringify)", (t) => { const url1 = stringifyPayUri({ - merchantBaseUrl: "http://localhost:123/", + merchantBaseUrl: "http://localhost:123/" as HostPortPath, orderId: "foo", sessionId: "", }); t.deepEqual(url1, "taler+http://pay/localhost:123/foo/"); const url2 = stringifyPayUri({ - merchantBaseUrl: "http://localhost:123/", + merchantBaseUrl: "http://localhost:123/" as HostPortPath, orderId: "foo", sessionId: "bla", }); @@ -170,14 +171,14 @@ test("taler pay URI (stringify)", (t) => { test("taler pay URI (stringify with https)", (t) => { const url1 = stringifyPayUri({ - merchantBaseUrl: "https://localhost:123/", + merchantBaseUrl: "https://localhost:123/" as HostPortPath, orderId: "foo", sessionId: "", }); t.deepEqual(url1, "taler://pay/localhost:123/foo/"); const url2 = stringifyPayUri({ - merchantBaseUrl: "https://localhost/", + merchantBaseUrl: "https://localhost/" as HostPortPath, orderId: "foo", sessionId: "bla", noncePriv: "123", @@ -196,7 +197,7 @@ test("taler refund uri parsing: non-https #1", (t) => { t.fail(); return; } - t.is(r1.merchantBaseUrl, "http://example.com/"); + t.is(r1.merchantBaseUrl, "http://example.com/" as HostPortPath); t.is(r1.orderId, "myorder"); }); @@ -207,7 +208,7 @@ test("taler refund uri parsing", (t) => { t.fail(); return; } - t.is(r1.merchantBaseUrl, "https://merchant.example.com/"); + t.is(r1.merchantBaseUrl, "https://merchant.example.com/" as HostPortPath); t.is(r1.orderId, "1234"); }); @@ -219,12 +220,12 @@ test("taler refund uri parsing with instance", (t) => { return; } t.is(r1.orderId, "1234"); - t.is(r1.merchantBaseUrl, "https://merchant.example.com/instances/myinst/"); + t.is(r1.merchantBaseUrl, "https://merchant.example.com/instances/myinst/" as HostPortPath); }); test("taler refund URI (stringify)", (t) => { const url = stringifyRefundUri({ - merchantBaseUrl: "https://merchant.test/instance/pepe/", + merchantBaseUrl: "https://merchant.test/instance/pepe/" as HostPortPath, orderId: "123", }); t.deepEqual(url, "taler://refund/merchant.test/instance/pepe/123/"); @@ -241,7 +242,7 @@ test("taler peer to peer push URI", (t) => { t.fail(); return; } - t.is(r1.exchangeBaseUrl, "https://exch.example.com/"); + t.is(r1.exchangeBaseUrl, "https://exch.example.com/" as HostPortPath); t.is(r1.contractPriv, "foo"); }); @@ -252,7 +253,7 @@ test("taler peer to peer push URI (path)", (t) => { t.fail(); return; } - t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/"); + t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/" as HostPortPath); t.is(r1.contractPriv, "foo"); }); @@ -263,13 +264,13 @@ test("taler peer to peer push URI (http)", (t) => { t.fail(); return; } - t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/"); + t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/" as HostPortPath); t.is(r1.contractPriv, "foo"); }); test("taler peer to peer push URI (stringify)", (t) => { const url = stringifyPayPushUri({ - exchangeBaseUrl: "https://foo.example.com/bla/", + exchangeBaseUrl: "https://foo.example.com/bla/" as HostPortPath, contractPriv: "123", }); t.deepEqual(url, "taler://pay-push/foo.example.com/bla/123"); @@ -286,7 +287,7 @@ test("taler peer to peer pull URI", (t) => { t.fail(); return; } - t.is(r1.exchangeBaseUrl, "https://exch.example.com/"); + t.is(r1.exchangeBaseUrl, "https://exch.example.com/" as HostPortPath); t.is(r1.contractPriv, "foo"); }); @@ -297,7 +298,7 @@ test("taler peer to peer pull URI (path)", (t) => { t.fail(); return; } - t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/"); + t.is(r1.exchangeBaseUrl, "https://exch.example.com:123/bla/" as HostPortPath); t.is(r1.contractPriv, "foo"); }); @@ -308,13 +309,13 @@ test("taler peer to peer pull URI (http)", (t) => { t.fail(); return; } - t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/"); + t.is(r1.exchangeBaseUrl, "http://exch.example.com:123/bla/" as HostPortPath); t.is(r1.contractPriv, "foo"); }); test("taler peer to peer pull URI (stringify)", (t) => { const url = stringifyPayPullUri({ - exchangeBaseUrl: "https://foo.example.com/bla/", + exchangeBaseUrl: "https://foo.example.com/bla/" as HostPortPath, contractPriv: "123", }); t.deepEqual(url, "taler://pay-pull/foo.example.com/bla/123"); @@ -332,7 +333,7 @@ test("taler pay template URI (parsing)", (t) => { t.fail(); return; } - t.deepEqual(r1.merchantBaseUrl, "https://merchant.example.com/"); + t.deepEqual(r1.merchantBaseUrl, "https://merchant.example.com/" as HostPortPath); t.deepEqual(r1.templateId, "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY"); }); @@ -344,13 +345,13 @@ test("taler pay template URI (parsing, http with port)", (t) => { t.fail(); return; } - t.deepEqual(r1.merchantBaseUrl, "http://merchant.example.com:1234/"); + t.deepEqual(r1.merchantBaseUrl, "http://merchant.example.com:1234/" as HostPortPath); t.deepEqual(r1.templateId, "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY"); }); test("taler pay template URI (stringify)", (t) => { const url1 = stringifyPayTemplateUri({ - merchantBaseUrl: "http://merchant.example.com:1234/", + merchantBaseUrl: "http://merchant.example.com:1234/" as HostPortPath, templateId: "FEGHYJY48FEGU6WETYIOIDEDE2QW3OCZVY", }); t.deepEqual( @@ -396,7 +397,7 @@ test("taler restore URI (parsing, https with port)", (t) => { test("taler restore URI (stringify)", (t) => { const url = stringifyRestoreUri({ walletRootPriv: "GJKG23V4ZBHEH45YRK7TWQE8ZTY7JWTY5094TQJSRZN5DSDBX8E0", - providers: ["http://prov1.example.com", "https://prov2.example.com:234/"], + providers: ["http://prov1.example.com" as HostPortPath, "https://prov2.example.com:234/" as HostPortPath], }); t.deepEqual( url, @@ -475,7 +476,7 @@ test("taler withdraw exchange URI (parse)", (t) => { return; } t.deepEqual(r3.amount, undefined); - t.deepEqual(r3.exchangeBaseUrl, "https://exchange.demo.taler.net/"); + t.deepEqual(r3.exchangeBaseUrl, "https://exchange.demo.taler.net/" as HostPortPath); } { @@ -488,20 +489,20 @@ test("taler withdraw exchange URI (parse)", (t) => { return; } t.deepEqual(r4.amount, undefined); - t.deepEqual(r4.exchangeBaseUrl, "https://exchange.demo.taler.net/"); + t.deepEqual(r4.exchangeBaseUrl, "https://exchange.demo.taler.net/" as HostPortPath); } }); test("taler withdraw exchange URI (stringify)", (t) => { const url = stringifyWithdrawExchange({ - exchangeBaseUrl: "https://exchange.demo.taler.net", + exchangeBaseUrl: "https://exchange.demo.taler.net" as HostPortPath, }); t.deepEqual(url, "taler://withdraw-exchange/exchange.demo.taler.net/"); }); test("taler withdraw exchange URI with amount (stringify)", (t) => { const url = stringifyWithdrawExchange({ - exchangeBaseUrl: "https://exchange.demo.taler.net", + exchangeBaseUrl: "https://exchange.demo.taler.net" as HostPortPath, amount: "KUDOS:19" as AmountString, }); t.deepEqual( @@ -523,7 +524,7 @@ test("taler add exchange URI (parse)", (t) => { t.fail(); return; } - t.deepEqual(r1.exchangeBaseUrl, "https://exchange.example.com/"); + t.deepEqual(r1.exchangeBaseUrl, "https://exchange.example.com/" as HostPortPath); } { const r2 = parseAddExchangeUri( @@ -533,13 +534,13 @@ test("taler add exchange URI (parse)", (t) => { t.fail(); return; } - t.deepEqual(r2.exchangeBaseUrl, "https://exchanges.example.com/api/"); + t.deepEqual(r2.exchangeBaseUrl, "https://exchanges.example.com/api/" as HostPortPath); } }); test("taler add exchange URI (stringify)", (t) => { const url = stringifyAddExchange({ - exchangeBaseUrl: "https://exchange.demo.taler.net", + exchangeBaseUrl: "https://exchange.demo.taler.net" as HostPortPath, }); t.deepEqual(url, "taler://add-exchange/exchange.demo.taler.net/"); }); diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts @@ -25,6 +25,14 @@ */ import { Codec, Context, DecodingError, renderContext } from "./codec.js"; import { canonicalizeBaseUrl } from "./helpers.js"; +import { + Amounts, + assertUnreachable, + opFixedSuccess, + opKnownFailure, + opKnownFailureWithBody, +} from "./index.js"; +import { HostPortPath, Paytos } from "./payto.js"; import { Result } from "./result.js"; import { TalerErrorCode } from "./taler-error-codes.js"; import { AmountString } from "./types-taler-common.js"; @@ -43,7 +51,8 @@ export type TalerUri = | RefundUriResult | WithdrawUriResult | WithdrawExchangeUri - | AddExchangeUri; + | AddExchangeUri + | WithdrawalTransferResultUri; declare const __action_str: unique symbol; export type TalerUriString = string & { [__action_str]: true }; @@ -66,9 +75,580 @@ export function codecForTalerUriString(): Codec<TalerUriString> { }; } +const TALER_PREFIX = "taler://"; +const TALER_HTTP_PREFIX = "taler+http://"; + +export enum TalerUriParseError { + /** + * URI should start with taler:// or taler+http:// + */ + WRONG_PREFIX, + /** + * URI should have a / after the target type + */ + INCOMPLETE, + /** + * URI type is not in the list of supported types + */ + UNSUPPORTED, + /** + * The quantity of components is wrong based on the target type + */ + COMPONENTS_LENGTH, + /** + * The validation of one path component failed + */ + INVALID_TARGET_PATH, + /** + * The validation of one parameter component failed + */ + INVALID_PARAMETER, +} + +export namespace TalerUris { + export type URI = TalerUri; + + const supported_targets: Record<TalerUriAction, true> = { + "add-exchange": true, + "dev-experiment": true, + pay: true, + "pay-pull": true, + "pay-template": true, + "pay-push": true, + "withdraw-exchange": true, + refund: true, + restore: true, + withdraw: true, + "withdrawal-transfer-result": true, + }; + + export function createTalerPay( + merchantBaseUrl: HostPortPath, + orderId: string, + sessionId: string, + opts: { + claimToken?: string; + noncePriv?: string; + } = {}, + ): PayUriResult { + return { + type: TalerUriAction.Pay, + merchantBaseUrl, + orderId, + sessionId, + ...opts, + }; + } + export function createTalerWithdraw( + bankIntegrationApiBaseUrl: HostPortPath, + withdrawalOperationId: string, + opts: { + externalConfirmation?: boolean; + } = {}, + ): WithdrawUriResult { + return { + type: TalerUriAction.Withdraw, + bankIntegrationApiBaseUrl, + withdrawalOperationId, + ...opts, + }; + } + export function createTalerRefund( + merchantBaseUrl: HostPortPath, + orderId: string, + ): RefundUriResult { + return { + type: TalerUriAction.Refund, + merchantBaseUrl, + orderId, + }; + } + export function createTalerPayPull( + exchangeBaseUrl: HostPortPath, + contractPriv: string, + ): PayPullUriResult { + return { + type: TalerUriAction.PayPull, + exchangeBaseUrl, + contractPriv, + }; + } + export function createTalerPayPush( + exchangeBaseUrl: HostPortPath, + contractPriv: string, + ): PayPushUriResult { + return { + type: TalerUriAction.PayPush, + exchangeBaseUrl, + contractPriv, + }; + } + export function createTalerPayTemplate( + merchantBaseUrl: HostPortPath, + templateId: string, + ): PayTemplateUriResult { + return { + type: TalerUriAction.PayTemplate, + merchantBaseUrl, + templateId, + }; + } + export function createTalerRestore( + walletRootPriv: string, + providers: HostPortPath[], + ): BackupRestoreUri { + return { + type: TalerUriAction.Restore, + providers, + walletRootPriv, + }; + } + export function createTalerDevExperiment( + devExperimentId: string, + // params: Record<string,string>, + query: URLSearchParams, // FIXME: Wrong type it should be Record<string,string> + ): DevExperimentUri { + return { + type: TalerUriAction.DevExperiment, + devExperimentId, + query, + }; + } + export function createTalerWithdrawExchange( + exchangeBaseUrl: HostPortPath, + opts: { + amount?: AmountString; + } = {}, + ): WithdrawExchangeUri { + return { + type: TalerUriAction.WithdrawExchange, + exchangeBaseUrl, + ...opts, + }; + } + export function createTalerAddExchange( + exchangeBaseUrl: HostPortPath, + ): AddExchangeUri { + return { + type: TalerUriAction.AddExchange, + exchangeBaseUrl, + }; + } + export function createTalerWithdrawalTransferResult( + ref: string, + opts: { + status?: "success" | "aborted"; + } = {}, + ): WithdrawalTransferResultUri { + return { + type: TalerUriAction.WithdrawalTransferResult, + ref, + ...opts, + }; + } + + export function fromString( + s: string, + opts: { + /** + * do not check path component format + */ + ignoreComponentError?: boolean; + } = {}, + ) { + // check prefix + let isHttp = false; + if ( + !s.startsWith(TALER_PREFIX) && + !(isHttp = s.startsWith(TALER_HTTP_PREFIX)) + ) { + return opKnownFailure(TalerUriParseError.WRONG_PREFIX); + } + const scheme = isHttp ? ("http" as const) : ("https" as const); + + // get path and search + const [path, search] = s + .slice((isHttp ? TALER_HTTP_PREFIX : TALER_PREFIX).length) + .split("?", 2); + + + // check if supported + const firstSlashPos = path.indexOf("/"); + const uriType = ( + firstSlashPos === -1 ? path : path.slice(0, firstSlashPos) + ) as TalerUriAction; + if (!supported_targets[uriType]) { + const d = opKnownFailureWithBody(TalerUriParseError.UNSUPPORTED, { + uriType, + }); + return d; + } + + const targetPath = path.slice(firstSlashPos + 1); + if (firstSlashPos === -1 || !targetPath) { + return opKnownFailureWithBody(TalerUriParseError.INCOMPLETE, { + uriType, + }); + } + + // parse params + const params: { [k: string]: string } = {}; + if (search) { + const searchParams = new URLSearchParams(search); + searchParams.forEach((v, k) => { + // URLSearchParams already decodes uri components + params[k] = v; + }); + } + + // get URI components + const cs = targetPath.split("/"); + switch (uriType) { + case TalerUriAction.Pay: { + // check number of segments + if (cs.length < 3) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get merchant host + const merchant = Paytos.parseHostPortPath( + cs.slice(0, -2).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !merchant) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: merchant, + }, + ); + } + + // get order + const orderId = cs[cs.length - 2]; + // get session + const sessionId = cs[cs.length - 1]; + + return opFixedSuccess<URI>( + createTalerPay( + merchant ?? (cs[0] as HostPortPath), + orderId, + sessionId, + { + claimToken: params["c"], + noncePriv: params["n"], + }, + ), + ); + } + case TalerUriAction.Withdraw: { + // check number of segments + if (cs.length < 2) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get bank host + const bank = Paytos.parseHostPortPath( + cs.slice(0, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !bank) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: bank, + }, + ); + } + + // get operation id + const operationId = cs[cs.length - 1]; + // get external confirmation + const externalConfirmation = !params["external-confirmation"] + ? undefined + : params["external-confirmation"] === "1"; + + return opFixedSuccess<URI>( + createTalerWithdraw(bank ?? (cs[0] as HostPortPath), operationId, { + externalConfirmation, + }), + ); + } + case TalerUriAction.Refund: { + // check number of segments + if (cs.length < 2) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get merchant host + const merchant = Paytos.parseHostPortPath( + cs.slice(0, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !merchant) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: merchant, + }, + ); + } + + // get order id + const orderId = cs[cs.length - 1]; + + return opFixedSuccess<URI>( + createTalerRefund(merchant ?? (cs[0] as HostPortPath), orderId), + ); + } + case TalerUriAction.PayPull: { + // check number of segments + if (cs.length < 2) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath( + cs.slice(0, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: exchange, + }, + ); + } + // get contract priv + const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + + return opFixedSuccess<URI>( + createTalerPayPull(exchange ?? (cs[0] as HostPortPath), contractPriv), + ); + } + case TalerUriAction.PayPush: { + // check number of segments + if (cs.length < 2) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath( + cs.slice(0, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: exchange, + }, + ); + } + + // get contract priv + const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + + return opFixedSuccess<URI>( + createTalerPayPush(exchange ?? (cs[0] as HostPortPath), contractPriv), + ); + } + case TalerUriAction.PayTemplate: { + // check number of segments + if (cs.length < 2) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get merchant host + const merchant = Paytos.parseHostPortPath( + cs.slice(0, -1).join("/"), + scheme, + ); + if (!opts.ignoreComponentError && !merchant) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: merchant, + }, + ); + } + + // get contract priv + const contractPriv = cs[cs.length - 1]; // FIXME: validate private key + + return opFixedSuccess<URI>( + createTalerPayTemplate( + merchant ?? (cs[0] as HostPortPath), + contractPriv, + ), + ); + } + case TalerUriAction.Restore: { + // check number of segments + if (cs.length !== 2) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + const walletPriv = cs[0]; // FIXME: validate private key + const providers: Array<HostPortPath> = []; + // const providers = new Array<HostPortPath>(); + cs[1].split(",").map((name) => { + const url = Paytos.parseHostPortPath( + decodeURIComponent(name), + scheme, + )!; + providers.push(url); + }); + + return opFixedSuccess<URI>( + createTalerRestore(walletPriv ?? (cs[0] as HostPortPath), providers), + ); + } + case TalerUriAction.DevExperiment: { + // check number of segments + if (cs.length !== 1) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + const experimentId = cs[0]; + const query = new URLSearchParams(search); + + return opFixedSuccess<URI>( + createTalerDevExperiment(experimentId, query), + ); + } + case TalerUriAction.WithdrawExchange: { + // check number of segments + if (cs.length !== 1) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath(cs.join("/"), scheme); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: exchange, + }, + ); + } + + // get amount param + const amountRes = !params["a"] + ? undefined + : Amounts.parseWithError(params["a"]); + if ( + !opts.ignoreComponentError && + amountRes && + amountRes.type === "fail" + ) { + return opKnownFailureWithBody(TalerUriParseError.INVALID_PARAMETER, { + name: "a" as const, + uriType, + error: amountRes, + }); + } + const amount = + amountRes && amountRes.type === "ok" + ? Amounts.stringify(amountRes.body) + : undefined; + + return opFixedSuccess<URI>( + createTalerWithdrawExchange(exchange ?? (cs[0] as HostPortPath), { + amount, + }), + ); + } + case TalerUriAction.AddExchange: { + // check number of segments + if (cs.length === 1) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + + // get exchange host + const exchange = Paytos.parseHostPortPath(cs.join("/"), scheme); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody( + TalerUriParseError.INVALID_TARGET_PATH, + { + pos: 0 as const, + uriType, + error: exchange, + }, + ); + } + + return opFixedSuccess<URI>( + createTalerAddExchange(exchange ?? (cs[0] as HostPortPath)), + ); + } + case TalerUriAction.WithdrawalTransferResult: { + if (cs.length === 0) { + return opKnownFailureWithBody(TalerUriParseError.COMPONENTS_LENGTH, { + uriType, + }); + } + const ref = params["ref"]; + const status = + params["status"] !== "aborted" && params["status"] !== "success" + ? undefined + : params["status"]; + + return opFixedSuccess<URI>( + createTalerWithdrawalTransferResult(ref, { + status, + }), + ); + } + default: { + assertUnreachable(uriType); + } + } + } +} + +/** + * + */ export interface PayUriResult { type: TalerUriAction.Pay; - merchantBaseUrl: string; + merchantBaseUrl: HostPortPath; orderId: string; sessionId: string; claimToken?: string; @@ -86,58 +666,61 @@ export type TemplateParams = { export interface PayTemplateUriResult { type: TalerUriAction.PayTemplate; - merchantBaseUrl: string; + merchantBaseUrl: HostPortPath; templateId: string; - // nfc?: boolean; - // amount?: boolean; } export interface WithdrawUriResult { type: TalerUriAction.Withdraw; - bankIntegrationApiBaseUrl: string; + bankIntegrationApiBaseUrl: HostPortPath; withdrawalOperationId: string; externalConfirmation?: boolean; } export interface RefundUriResult { type: TalerUriAction.Refund; - merchantBaseUrl: string; + merchantBaseUrl: HostPortPath; orderId: string; } export interface PayPushUriResult { type: TalerUriAction.PayPush; - exchangeBaseUrl: string; + exchangeBaseUrl: HostPortPath; contractPriv: string; } export interface PayPullUriResult { type: TalerUriAction.PayPull; - exchangeBaseUrl: string; + exchangeBaseUrl: HostPortPath; contractPriv: string; } export interface DevExperimentUri { type: TalerUriAction.DevExperiment; devExperimentId: string; - query?: URLSearchParams; + query?: URLSearchParams; // FIXME: Wrong type it should be Record<string,string> } export interface BackupRestoreUri { type: TalerUriAction.Restore; walletRootPriv: string; - providers: Array<string>; + providers: Array<HostPortPath>; } export interface WithdrawExchangeUri { type: TalerUriAction.WithdrawExchange; - exchangeBaseUrl: string; + exchangeBaseUrl: HostPortPath; amount?: AmountString; } export interface AddExchangeUri { type: TalerUriAction.AddExchange; - exchangeBaseUrl: string; + exchangeBaseUrl: HostPortPath; +} +export interface WithdrawalTransferResultUri { + type: TalerUriAction.WithdrawalTransferResult; + ref: string; + status?: "success" | "aborted"; } /** @@ -174,9 +757,10 @@ export function parseWithdrawUriWithError(s: string) { const result: WithdrawUriResult = { type: TalerUriAction.Withdraw, - bankIntegrationApiBaseUrl: canonicalizeBaseUrl( - `${pi.value.innerProto}://${p}/`, - ), + bankIntegrationApiBaseUrl: Paytos.parseHostPortPath( + p, + pi.value.innerProto, + )!, withdrawalOperationId: withdrawId, externalConfirmation: q.get("external-confirmation") == "1", }; @@ -221,7 +805,7 @@ export function parseAddExchangeUriWithError(s: string) { const result: AddExchangeUri = { type: TalerUriAction.AddExchange, - exchangeBaseUrl: canonicalizeBaseUrl(`${pi.value.innerProto}://${p}/`), + exchangeBaseUrl: Paytos.parseHostPortPath(p, pi.value.innerProto)!, }; return Result.of(result); } @@ -236,34 +820,51 @@ export function parseAddExchangeUri(s: string): AddExchangeUri | undefined { return r.value; } -/** - * @deprecated use TalerUriAction - */ -export enum TalerUriType { - TalerPay = "taler-pay", - TalerTemplate = "taler-template", - TalerPayTemplate = "taler-pay-template", - TalerWithdraw = "taler-withdraw", - TalerTip = "taler-tip", - TalerRefund = "taler-refund", - TalerPayPush = "taler-pay-push", - TalerPayPull = "taler-pay-pull", - TalerRecovery = "taler-recovery", - TalerDevExperiment = "taler-dev-experiment", - Unknown = "unknown", -} - export enum TalerUriAction { - Pay = "pay", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.1 + */ Withdraw = "withdraw", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.2 + */ + Pay = "pay", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.3 + */ Refund = "refund", - PayPull = "pay-pull", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.4 + */ PayPush = "pay-push", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.5 + */ + PayPull = "pay-pull", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.6 + */ PayTemplate = "pay-template", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.7 + */ Restore = "restore", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.8 + */ DevExperiment = "dev-experiment", - WithdrawExchange = "withdraw-exchange", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.9 + */ AddExchange = "add-exchange", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.10 + */ + WithdrawExchange = "withdraw-exchange", + /** + * https://lsd.gnunet.org/lsd0006/#section-5.11 + */ + WithdrawalTransferResult = "withdrawal-transfer-result", } interface TalerUriProtoInfo { @@ -293,7 +894,7 @@ function parseProtoInfo( } interface ProtoInfo { - innerProto: string; + innerProto: "http" | "https"; rest: string; } @@ -336,6 +937,9 @@ const parsers: { [A in TalerUriAction]: Parser } = { [TalerUriAction.DevExperiment]: parseDevExperimentUri, [TalerUriAction.WithdrawExchange]: parseWithdrawExchangeUri, [TalerUriAction.AddExchange]: parseAddExchangeUri, + [TalerUriAction.WithdrawalTransferResult]: () => { + throw new Error("not supported"); + }, }; export function parseTalerUri(string: string): TalerUri | undefined { @@ -382,6 +986,9 @@ export function stringifyTalerUri(uri: TalerUri): string { case TalerUriAction.AddExchange: { return stringifyAddExchange(uri); } + case TalerUriAction.WithdrawalTransferResult: { + throw Error("not supported"); + } } } @@ -407,7 +1014,7 @@ export function parsePayUri(s: string): PayUriResult | undefined { const orderId = parts[parts.length - 2]; const pathSegments = parts.slice(1, parts.length - 2); const p = [host, ...pathSegments].join("/"); - const merchantBaseUrl = canonicalizeBaseUrl(`${pi.innerProto}://${p}/`); + const merchantBaseUrl = Paytos.parseHostPortPath(p, pi.innerProto)!; return { type: TalerUriAction.Pay, @@ -443,10 +1050,14 @@ export function parsePayTemplateUri( const templateId = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); - const merchantBaseUrl = canonicalizeBaseUrl( - `${pi.innerProto}://${hostAndSegments}/`, - ); - + // const merchantBaseUrl = canonicalizeBaseUrl( + // `${pi.innerProto}://${hostAndSegments}/`, + // ); + + const merchantBaseUrl = Paytos.parseHostPortPath( + hostAndSegments, + pi.innerProto, + )!; return { type: TalerUriAction.PayTemplate, merchantBaseUrl, @@ -468,9 +1079,13 @@ export function parsePayPushUri(s: string): PayPushUriResult | undefined { const contractPriv = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); - const exchangeBaseUrl = canonicalizeBaseUrl( - `${pi.innerProto}://${hostAndSegments}/`, - ); + // const exchangeBaseUrl = canonicalizeBaseUrl( + // `${pi.innerProto}://${hostAndSegments}/`, + // ); + const exchangeBaseUrl = Paytos.parseHostPortPath( + hostAndSegments, + pi.innerProto, + )!; return { type: TalerUriAction.PayPush, @@ -493,9 +1108,13 @@ export function parsePayPullUri(s: string): PayPullUriResult | undefined { const contractPriv = parts[parts.length - 1]; const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); - const exchangeBaseUrl = canonicalizeBaseUrl( - `${pi.innerProto}://${hostAndSegments}/`, - ); + // const exchangeBaseUrl = canonicalizeBaseUrl( + // `${pi.innerProto}://${hostAndSegments}/`, + // ); + const exchangeBaseUrl = Paytos.parseHostPortPath( + hostAndSegments, + pi.innerProto, + )!; return { type: TalerUriAction.PayPull, @@ -527,9 +1146,14 @@ export function parseWithdrawExchangeUri( } const pathSegments = parts.slice(1, parts.length - 1); const hostAndSegments = [host, ...pathSegments].join("/"); - const exchangeBaseUrl = canonicalizeBaseUrl( - `${pi.innerProto}://${hostAndSegments}/`, - ); + // const exchangeBaseUrl = canonicalizeBaseUrl( + // `${pi.innerProto}://${hostAndSegments}/`, + // ); + const exchangeBaseUrl = Paytos.parseHostPortPath( + hostAndSegments, + pi.innerProto, + )!; + const q = new URLSearchParams(c[1] ?? ""); const amount = (q.get("a") ?? undefined) as AmountString | undefined; @@ -559,9 +1183,13 @@ export function parseRefundUri(s: string): RefundUriResult | undefined { const orderId = parts[parts.length - 2]; const pathSegments = parts.slice(1, parts.length - 2); const hostAndSegments = [host, ...pathSegments].join("/"); - const merchantBaseUrl = canonicalizeBaseUrl( - `${pi.innerProto}://${hostAndSegments}/`, - ); + // const merchantBaseUrl = canonicalizeBaseUrl( + // `${pi.innerProto}://${hostAndSegments}/`, + // ); + const merchantBaseUrl = Paytos.parseHostPortPath( + hostAndSegments, + pi.innerProto, + )!; return { type: TalerUriAction.Refund, @@ -597,11 +1225,12 @@ export function parseRestoreUri(uri: string): BackupRestoreUri | undefined { const walletRootPriv = parts[0]; if (!walletRootPriv) return undefined; - const providers = new Array<string>(); + const providers = new Array<HostPortPath>(); parts[1].split(",").map((name) => { - const url = canonicalizeBaseUrl( - `${pi.innerProto}://${decodeURIComponent(name)}/`, - ); + const url = Paytos.parseHostPortPath( + decodeURIComponent(name), + pi.innerProto, + )!; providers.push(url); }); return { diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -60,6 +60,7 @@ import { getRandomBytes, HashCodeString, hashPayWalletData, + HostPortPath, HttpStatusCode, j2s, Logger, @@ -1948,7 +1949,7 @@ async function checkPaymentByProposalId( const talerUri = stringifyTalerUri({ type: TalerUriAction.Pay, - merchantBaseUrl: proposal.merchantBaseUrl, + merchantBaseUrl: proposal.merchantBaseUrl as HostPortPath, // FIXME: change record type orderId: proposal.orderId, sessionId: proposal.lastSessionId ?? proposal.downloadSessionId ?? "", claimToken: proposal.claimToken, @@ -4196,7 +4197,7 @@ export async function sharePayment( wex.taskScheduler.startShepherdTask(ctx.taskId); const privatePayUri = stringifyPayUri({ - merchantBaseUrl, + merchantBaseUrl: merchantBaseUrl as HostPortPath, // FIXME: change function argument orderId, sessionId: result.session ?? "", noncePriv: result.nonce, diff --git a/packages/taler-wallet-core/src/pay-peer-pull-credit.ts b/packages/taler-wallet-core/src/pay-peer-pull-credit.ts @@ -21,6 +21,7 @@ import { ContractTermsUtil, ExchangeReservePurseRequest, ExchangeWalletKycStatus, + HostPortPath, HttpStatusCode, InitiatePeerPullCreditRequest, InitiatePeerPullCreditResponse, @@ -254,7 +255,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { iconId: peerContractTerms.icon_id, }, talerUri: stringifyPayPullUri({ - exchangeBaseUrl: wsr.exchangeBaseUrl, + exchangeBaseUrl: wsr.exchangeBaseUrl as HostPortPath, // FIXME: change record type contractPriv: wsr.wgInfo.contractPriv, }), transactionId: this.transactionId, @@ -291,7 +292,7 @@ export class PeerPullCreditTransactionContext implements TransactionContext { iconId: peerContractTerms.icon_id, }, talerUri: stringifyPayPullUri({ - exchangeBaseUrl: pullCredit.exchangeBaseUrl, + exchangeBaseUrl: pullCredit.exchangeBaseUrl as HostPortPath, // FIXME: change record type contractPriv: pullCredit.contractPriv, }), transactionId: this.transactionId, @@ -1135,7 +1136,7 @@ export async function initiatePeerPullPayment( return { talerUri: stringifyTalerUri({ type: TalerUriAction.PayPull, - exchangeBaseUrl: exchangeBaseUrl, + exchangeBaseUrl: exchangeBaseUrl as HostPortPath, // FIXME: change record type, contractPriv: contractKeyPair.priv, }), transactionId: ctx.transactionId, diff --git a/packages/taler-wallet-core/src/pay-peer-push-debit.ts b/packages/taler-wallet-core/src/pay-peer-push-debit.ts @@ -22,6 +22,7 @@ import { CoinRefreshRequest, ContractTermsUtil, ExchangePurseDeposits, + HostPortPath, HttpStatusCode, InitiatePeerPushDebitRequest, InitiatePeerPushDebitResponse, @@ -165,7 +166,7 @@ export class PeerPushDebitTransactionContext implements TransactionContext { case PeerPushDebitStatus.PendingReady: case PeerPushDebitStatus.SuspendedReady: talerUri = stringifyPayPushUri({ - exchangeBaseUrl: pushDebitRec.exchangeBaseUrl, + exchangeBaseUrl: pushDebitRec.exchangeBaseUrl as HostPortPath, // FIXME: change record type contractPriv: pushDebitRec.contractPriv, }); } diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx @@ -177,6 +177,7 @@ export const Pages = { scope: CrockEncodedString; amount?: string; }>("/cta/scope-withdraw/:scope/:amount?"), + ctaWithdrawTransferResult: pageDefinition("/cta/transfer-result"), ctaWithdrawManual: pageDefinition<{amount?: string;}>("/cta/manual-withdraw/:amount?"), paytoQrs: pageDefinition<{ payto: CrockEncodedString }>("/payto/qrs/:payto?"), paytoBanks: pageDefinition<{ payto: CrockEncodedString }>( @@ -197,6 +198,7 @@ const talerUriActionToPageName: { [TalerUriAction.WithdrawExchange]: "ctaWithdrawManual", [TalerUriAction.DevExperiment]: "ctaExperiment", [TalerUriAction.AddExchange]: "ctaAddExchange", + [TalerUriAction.WithdrawalTransferResult]: "ctaWithdrawTransferResult", }; export function getPathnameForTalerURI(talerUri: string): string | undefined { diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -234,6 +234,9 @@ function openWalletURIFromPopup(uri: TalerUri): void { case TalerUriAction.DevExperiment: logger.warn(`taler://dev-experiment URIs are not allowed in headers`); return; + case TalerUriAction.WithdrawalTransferResult: + logger.warn(`taler://withdrawal-transfer-result URIs are not allowed in headers`); + return; default: { const error: never = uri; logger.warn( diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx @@ -93,6 +93,7 @@ function ContentByUriType({ case TalerUriAction.PayPull: case TalerUriAction.PayPush: case TalerUriAction.Restore: + case TalerUriAction.WithdrawalTransferResult: return null; default: { const error: never = uri; diff --git a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx @@ -16,6 +16,7 @@ import { AbsoluteTime, + HostPortPath, ProviderInfo, ProviderPaymentPaid, ProviderPaymentStatus, @@ -136,7 +137,7 @@ export function BackupPage({ onAddProvider }: Props): VNode { ); const str = stringifyRestoreUri({ walletRootPriv: r.walletRootPriv, - providers: r.providers.map((p) => p.url), + providers: r.providers.map((p) => p.url as HostPortPath), }); setRecoveryInfo(str); } diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx @@ -20,6 +20,7 @@ import { CoinStatus, ExchangeListItem, ExchangeTosStatus, + HostPortPath, LogLevel, NotificationType, ScopeType, @@ -144,7 +145,7 @@ function ExchangeActions({}: {}): VNode { const uri = !e.masterPub ? undefined : stringifyWithdrawExchange({ - exchangeBaseUrl: e.exchangeBaseUrl, + exchangeBaseUrl: e.exchangeBaseUrl as HostPortPath, }); return ( <tr key={idx}> diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx b/packages/taler-wallet-webextension/src/wallet/ManageAccount/views.tsx @@ -15,27 +15,31 @@ */ import { - buildPayto, - WalletBankAccountInfo, + BtAddrString, + HostPortPath, + IbanString, + InternationalizationAPI, + OperationOk, + parseIban, + ParseIbanError, parsePaytoUri, + Paytos, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, - stringifyPaytoUri, - validateIban, + TranslatedString, + WalletBankAccountInfo } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { styled } from "@linaria/react"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ErrorMessage } from "../../components/ErrorMessage.js"; -import { SubTitle, SvgIcon } from "../../components/styled/index.js"; +import { SubTitle } from "../../components/styled/index.js"; import { Button } from "../../mui/Button.js"; import { TextFieldHandler } from "../../mui/handlers.js"; import { TextField } from "../../mui/TextField.js"; -import checkIcon from "../../svg/check_24px.inline.svg"; import deleteIcon from "../../svg/delete_24px.inline.svg"; -import warningIcon from "../../svg/warning_24px.inline.svg"; import { State } from "./index.js"; type AccountType = "bitcoin" | "x-taler-bank" | "iban"; @@ -444,8 +448,8 @@ function BitcoinAddressAccount({ field }: { field: TextFieldHandler }): VNode { onChange={(v) => { setValue(v); if (!errors && field.onInput) { - const p = buildPayto("bitcoin", v, undefined); - field.onInput(stringifyPaytoUri(p)); + const p = Paytos.createBitcoin(v as BtAddrString, undefined); + field.onInput(Paytos.toFullString(p)); } }} /> @@ -488,8 +492,8 @@ function TalerBankAddressAccount({ onChange={(v) => { setHost(v); if (!errors && field.onInput && account) { - const p = buildPayto("x-taler-bank", v, account); - field.onInput(stringifyPaytoUri(p)); + const p = Paytos.createTalerBank(v as HostPortPath, account); + field.onInput(Paytos.toFullString(p)); } }} /> @@ -503,15 +507,36 @@ function TalerBankAddressAccount({ onChange={(v) => { setAccount(v || ""); if (!errors && field.onInput && host) { - const p = buildPayto("x-taler-bank", host, v); - field.onInput(stringifyPaytoUri(p)); + const p = Paytos.createTalerBank(host as HostPortPath, v); + field.onInput(Paytos.toFullString(p)); } }} /> </Fragment> ); } +type FailCasesOf<T extends (...args: any) => any> = Exclude< + ReturnType<T>, + OperationOk<any> +>; +function translateIbanError( + result: FailCasesOf<typeof parseIban>, + i18n: InternationalizationAPI, +): TranslatedString { + switch (result.case) { + case ParseIbanError.UNSUPPORTED_COUNTRY: + return i18n.str`Unsupported country.`; + case ParseIbanError.TOO_LONG: + return i18n.str`IBANs have fewer than 34 characters.`; + case ParseIbanError.TOO_SHORT: + return i18n.str`IBANs have more than 4 characters.`; + case ParseIbanError.INVALID_CHARSET: + return i18n.str`It should only contain numbers and letters.`; + case ParseIbanError.INVALID_CHECKSUM: + return i18n.str`The checksum is wrong.`; + } +} //Taken from libeufin and libeufin took it from the ISO20022 XSD schema // const bicRegex = /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/; // const ibanRegex = /^[A-Z]{2}[0-9]{2}[a-zA-Z0-9]{1,30}$/; @@ -530,10 +555,8 @@ function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode { // ? i18n.str`Invalid bic` // : undefined, iban: !iban - ? i18n.str`Can't be empty` - : validateIban(iban).type === "invalid" - ? i18n.str`Invalid iban` - : undefined, + ? i18n.str`Required` + : validateIBAN(iban, i18n), name: !name ? i18n.str`Can't be empty` : undefined, }); const errors = errorsFN(iban, name); @@ -545,9 +568,9 @@ function IbanAddressAccount({ field }: { field: TextFieldHandler }): VNode { ): void { if (!field.onInput) return; if (!errorsFN(iban, name)) { - const p = buildPayto("iban", iban, bic); + const p = Paytos.createIban(iban as IbanString, bic); p.params["receiver-name"] = name; - field.onInput(stringifyPaytoUri(p)); + field.onInput(Paytos.toFullString(p)); } else { field.onInput(""); } @@ -625,3 +648,17 @@ function CustomFieldByAccountType({ </div> ); } + +function validateIBAN( + iban: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): TranslatedString | undefined { + if (!iban) { + return i18n.str`Required`; + } + const result = parseIban(iban); + if (result.type === "ok") { + return undefined; + } + return translateIbanError(result, i18n); +} diff --git a/packages/taler-wallet-webextension/src/wallet/QrReader.tsx b/packages/taler-wallet-webextension/src/wallet/QrReader.tsx @@ -436,6 +436,8 @@ export function QrReaderPage({ onDetected }: Props): VNode { ); case TalerUriAction.AddExchange: return <i18n.Translate>Add exchange</i18n.Translate>; + case TalerUriAction.WithdrawalTransferResult: + return <i18n.Translate>Notify transaction</i18n.Translate>; default: { assertUnreachable(talerUri); } @@ -482,11 +484,9 @@ async function testValidUri( i18n: InternationalizationAPI, ): Promise<TranslatedString | undefined> { switch (uri.type) { - case TalerUriAction.Pay: { - const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n); - if (errorExchange) { - return errorExchange; - } + case TalerUriAction.Restore: + case TalerUriAction.DevExperiment: + case TalerUriAction.WithdrawalTransferResult: { return undefined; } case TalerUriAction.Withdraw: { @@ -499,26 +499,18 @@ async function testValidUri( } return undefined; } - case TalerUriAction.Refund: { - const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n); - if (errorExchange) { - return errorExchange; - } - return undefined; - } - case TalerUriAction.PayTemplate: { + case TalerUriAction.Refund: + case TalerUriAction.PayTemplate: + case TalerUriAction.Pay: { const errorExchange = await checkMerchantUrl(uri.merchantBaseUrl, i18n); if (errorExchange) { return errorExchange; } return undefined; } - case TalerUriAction.Restore: { - return undefined; - } - case TalerUriAction.DevExperiment: { - return undefined; - } + case TalerUriAction.PayPush: + case TalerUriAction.AddExchange: + case TalerUriAction.WithdrawExchange: case TalerUriAction.PayPull: { const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); if (errorExchange) { @@ -526,27 +518,6 @@ async function testValidUri( } return undefined; } - case TalerUriAction.PayPush: { - const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); - if (errorExchange) { - return errorExchange; - } - return undefined; - } - case TalerUriAction.AddExchange: { - const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); - if (errorExchange) { - return errorExchange; - } - return undefined; - } - case TalerUriAction.WithdrawExchange: { - const errorExchange = await checkExchangeUrl(uri.exchangeBaseUrl, i18n); - if (errorExchange) { - return errorExchange; - } - return undefined; - } default: { assertUnreachable(uri); }