taler-typescript-core

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

commit a3308d5fb99beb2e036e4d6ae5a9d6238a990449
parent 35c07e23749c9e77245da3af3eef62f3f85809f4
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue, 30 Sep 2025 15:01:45 -0300

fixes #8526

Diffstat:
Mpackages/aml-backoffice-ui/src/Routing.tsx | 15+++++++--------
Mpackages/aml-backoffice-ui/src/components/CreateAccount.tsx | 4++--
Mpackages/aml-backoffice-ui/src/components/NewMeasure.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/AccountDetails.tsx | 2--
Mpackages/aml-backoffice-ui/src/pages/DecisionWizard.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 277++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Properties.tsx | 2--
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 2+-
Mpackages/bank-ui/src/hooks/account.ts | 6+++---
Mpackages/bank-ui/src/hooks/preferences.ts | 7-------
Mpackages/bank-ui/src/pages/account/ShowAccountDetails.tsx | 27+--------------------------
Mpackages/merchant-backoffice-ui/src/components/modal/index.tsx | 86-------------------------------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx | 43+------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx | 23++++++++++++++++++-----
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx | 75+++++++++------------------------------------------------------------------
Mpackages/taler-harness/src/env1.ts | 4++--
Mpackages/taler-harness/src/harness/harness.ts | 6+++---
Mpackages/taler-harness/src/index.ts | 19++++++++++++-------
Mpackages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-rewrite.ts | 3++-
Mpackages/taler-harness/src/integrationtests/test-merchant-acctsel.ts | 5+++--
Mpackages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts | 4++--
Mpackages/taler-harness/src/integrationtests/test-withdrawal-huge.ts | 3++-
Mpackages/taler-util/src/bank-api-client.ts | 2+-
Mpackages/taler-util/src/bech32.ts | 194++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-util/src/bitcoin.test.ts | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/taler-util/src/bitcoin.ts | 55+++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-util/src/iban.ts | 331+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/taler-util/src/index.ts | 1+
Mpackages/taler-util/src/operation.ts | 6+++---
Mpackages/taler-util/src/payto.ts | 799+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/taler-util/src/segwit_addr.ts | 124+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/taler-util/src/types-taler-merchant.ts | 2+-
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/index.ts | 4++--
Mpackages/taler-wallet-webextension/src/wallet/DepositPage/state.ts | 9+++++----
Mpackages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts | 4++--
Mpackages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts | 3++-
38 files changed, 1591 insertions(+), 733 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -25,24 +25,23 @@ import { Fragment, h, VNode } from "preact"; import { assertUnreachable } from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; +import { HandleAccountNotReady } from "./components/HandleAccountNotReady.js"; import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js"; import { useCurrentDecisionRequest } from "./hooks/decision-request.js"; import { useExpireSessionAfter1hr, useOfficer } from "./hooks/officer.js"; import { AccountDetails } from "./pages/AccountDetails.js"; -import { AccountList } from "./pages/AccountList.js"; +import { + AccountList, HomeIcon, + PeopleIcon, + SearchIcon, + TransfersIcon +} from "./pages/AccountList.js"; import { Dashboard } from "./pages/Dashboard.js"; import { DecisionWizard, WizardSteps } from "./pages/DecisionWizard.js"; -import { HandleAccountNotReady } from "./components/HandleAccountNotReady.js"; import { Profile } from "./pages/Profile.js"; import { Search } from "./pages/Search.js"; import { ShowCollectedInfo } from "./pages/ShowCollectedInfo.js"; import { Transfers } from "./pages/Transfers.js"; -import { - HomeIcon, - PeopleIcon, - SearchIcon, - TransfersIcon, -} from "./pages/AccountList.js"; const TALER_SCREEN_ID = 126; diff --git a/packages/aml-backoffice-ui/src/components/CreateAccount.tsx b/packages/aml-backoffice-ui/src/components/CreateAccount.tsx @@ -47,7 +47,7 @@ const createAccountForm = ( required: true, validator(value) { return !value - ? i18n.str`required` + ? i18n.str`Required` : allowInsecurePassword ? undefined : value.length < 12 @@ -68,7 +68,7 @@ const createAccountForm = ( required: true, validator(value) { return !value - ? i18n.str`required` + ? i18n.str`Required` : // : state.password !== value // ? i18n.str`doesn't match` undefined; diff --git a/packages/aml-backoffice-ui/src/components/NewMeasure.tsx b/packages/aml-backoffice-ui/src/components/NewMeasure.tsx @@ -552,7 +552,7 @@ const formDesign = ( label: i18n.str`Name`, validator(value) { return !value - ? i18n.str`required` + ? i18n.str`Required` : summary.roots[value] ? i18n.str`There is already a measure with that name` : undefined; @@ -993,7 +993,7 @@ const verificationFormDesign = ( help: i18n.str`Name of the verification measure`, validator(value) { return !value - ? i18n.str`required` + ? i18n.str`Required` : summary.roots[value] ? i18n.str`There is already a measure with that name` : undefined; diff --git a/packages/aml-backoffice-ui/src/pages/AccountDetails.tsx b/packages/aml-backoffice-ui/src/pages/AccountDetails.tsx @@ -181,8 +181,6 @@ export function AccountDetails({ : BANK_RULES.includes(r.operation_type); }); - console.log(activeDecision?.properties); - return ( <div class="min-w-60"> <header class="flex flex-col justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 gap-2"> diff --git a/packages/aml-backoffice-ui/src/pages/DecisionWizard.tsx b/packages/aml-backoffice-ui/src/pages/DecisionWizard.tsx @@ -113,7 +113,7 @@ export function DecisionWizard({ onMove, }: { account: string; - newPayto?: PaytoString; + newPayto?: string; formId: string | undefined; step?: WizardSteps; onMove: (n: WizardSteps | undefined) => void; @@ -356,7 +356,7 @@ function Header({ account, }: { account: string; - newPayto: PaytoString | undefined; + newPayto: string | undefined; }): VNode { const { i18n } = useTranslationContext(); const isNewAccount = !!newPayto; diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -16,18 +16,25 @@ import { AbsoluteTime, assertUnreachable, + BitcoinBech32, buildPayto, encodeCrock, getURLHostnamePortPath, hashNormalizedPaytoUri, + HostPortPath, HttpStatusCode, - IbanError, + IbanString, + OperationOk, + parseIban, + ParseIbanError, parsePaytoUri, + PaytoParseError, + Paytos, PaytoUri, + ReservePubParseError, stringifyPaytoUri, TalerError, TranslatedString, - validateIban, } from "@gnu-taler/taler-util"; import { Attention, @@ -65,7 +72,7 @@ export function Search({ const officer = useOfficer(); const { i18n } = useTranslationContext(); - const [paytoUri, setPayto] = useState<PaytoUri | undefined>(undefined); + const [paytoUri, setPayto] = useState<Paytos.URI | undefined>(undefined); if (officer.state !== "ready") { return <HandleAccountNotReady officer={officer} />; @@ -132,11 +139,11 @@ function ShowResult({ }: { routeToAccountById: RouteDefinition<{ cid: string }>; - payto: PaytoUri; + payto: Paytos.URI; onNewDecision: (account: string, payto: string) => void; }): VNode { - const paytoStr = stringifyPaytoUri(payto); - const account = encodeCrock(hashNormalizedPaytoUri(paytoStr)); + const paytoStr = Paytos.toNormalizedString(payto); + const account = encodeCrock(Paytos.hash(paytoStr)); const { i18n } = useTranslationContext(); const history = useAccountDecisions(account); @@ -314,7 +321,7 @@ function ShowResult({ function XTalerBankForm({ onSearch, }: { - onSearch: (p: PaytoUri | undefined) => void; + onSearch: (p: Paytos.URI | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); const design: FormDesign = { @@ -329,8 +336,7 @@ function XTalerBankForm({ const paytoUri = form.status.status === "fail" ? undefined - : buildPayto( - "x-taler-bank", + : Paytos.createTalerBank( form.status.result.hostname, form.status.result.account, ); @@ -361,22 +367,22 @@ function XTalerBankForm({ function IbanForm({ onSearch, }: { - onSearch: (p: PaytoUri | undefined) => void; + onSearch: (p: Paytos.URI | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); const design: FormDesign = { type: "single-column", fields: ibanFields(i18n), }; - const form = useForm<PaytoUriIBANForm>( - design, - {}, - // createIbanPaytoValidator(i18n), - ); + const form = useForm<PaytoUriIBANForm>(design, {}); const paytoUri = form.status.status === "fail" ? undefined - : buildPayto("iban", form.status.result.account, undefined); + : Paytos.createIbanPayto( + form.status.result.account as IbanString, + undefined, + {}, + ); return ( <form @@ -403,7 +409,7 @@ function IbanForm({ function WalletForm({ onSearch, }: { - onSearch: (p: PaytoUri | undefined) => void; + onSearch: (p: Paytos.URI | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); const { config } = useExchangeApiContext(); @@ -418,14 +424,12 @@ function WalletForm({ }, // createTalerPaytoValidator(i18n), ); + const pub = Paytos.parseReservePub(form.status.result.reservePub); + const paytoUri = - form.status.status === "fail" + form.status.status === "fail" || pub.type === "fail" ? undefined - : buildPayto( - "taler-reserve", - form.status.result.exchange, - form.status.result.reservePub, - ); + : Paytos.createTalerReserve(form.status.result.exchange, pub.body); return ( <form @@ -453,7 +457,7 @@ function WalletForm({ function GenericForm({ onSearch, }: { - onSearch: (p: PaytoUri | undefined) => void; + onSearch: (p: Paytos.URI | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); const design: FormDesign = { @@ -465,10 +469,13 @@ function GenericForm({ {}, // createGenericPaytoValidator(i18n), ); + let p; const paytoUri = form.status.status === "fail" ? undefined - : parsePaytoUri(form.status.result.payto); + : (p = Paytos.fromString(form.status.result.payto)).type === "fail" + ? undefined + : p.body; return ( <form class="space-y-6" @@ -504,12 +511,12 @@ interface PaytoUriIBANForm { } interface PaytoUriTalerBankForm { - hostname: string; + hostname: HostPortPath; account: string; } interface PaytoUriTalerForm { - exchange: string; + exchange: HostPortPath; reservePub: string; } @@ -542,6 +549,165 @@ const paytoTypeField: ( }, ]; +type FailCasesOf<T extends (...args: any) => any> = Exclude< + ReturnType<T>, + OperationOk<any> +>; +function translateReservePubError( + result: FailCasesOf<typeof Paytos.parseReservePub>, + i18n: InternationalizationAPI, +): TranslatedString { + switch (result.case) { + case ReservePubParseError.WRONG_LENGTH: + return i18n.str`Should be 52 characters.`; + case ReservePubParseError.DECODE_ERROR: + return i18n.str`Failed to parse: ${result.body.message}`; + } +} + +function translateBitcoinError( + result: FailCasesOf<typeof BitcoinBech32.decode>, + i18n: InternationalizationAPI, +): TranslatedString { + switch (result.case) { + case BitcoinBech32.BitcoinParseError.WRONG_CHARSET: + return i18n.str`Address contains invalid characters.`; + case BitcoinBech32.BitcoinParseError.MIXING_UPPER_AND_LOWER: + return i18n.str`Can't mix uppercased and lowercased characters.`; + case BitcoinBech32.BitcoinParseError.WRONG_CHECKSUM: + return i18n.str`The addr checksum doesn't match.`; + case BitcoinBech32.BitcoinParseError.MISSING_HRP: + return i18n.str`Missing separator or address type.`; + case BitcoinBech32.BitcoinParseError.TOO_LONG: + return i18n.str`Address should have less than 90 characters.`; + case BitcoinBech32.BitcoinParseError.TOO_SHORT: + return i18n.str`Address should have more than 6 characters.`; + } +} + +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.`; + } +} + +function translatePaytoError( + result: FailCasesOf<typeof Paytos.fromString>, + i18n: InternationalizationAPI, +): TranslatedString { + switch (result.case) { + case PaytoParseError.WRONG_PREFIX: + return i18n.str`The string should start with payto://`; + case PaytoParseError.INCOMPLETE: + return i18n.str`After the target type it should follow a '/' with more information.`; + case PaytoParseError.UNSUPPORTED: + 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": + return i18n.str`IBAN is missing`; + case "bitcoin": + return i18n.str`Bitcoin address is missing`; + case "x-taler-bank": + return i18n.str`Bank host and account is missing`; + case "taler-reserve": + return i18n.str`Exchange host and account is missing`; + case "taler-reserve-http": + return i18n.str`Exchange host and account is missing`; + case "ethereum": + return i18n.str`Ethereum address is missing`; + } + } + case PaytoParseError.INVALID_TARGET_PATH: { + switch (result.body.targetType) { + case "iban": + return i18n.str`Invalid IBAN: ${translateIbanError( + result.body.error, + i18n, + )}`; + case "bitcoin": { + switch (result.body.pos) { + case 0: + return i18n.str`Invalid BTC: ${translateBitcoinError( + result.body.error, + i18n, + )}`; + case 1: + return i18n.str`Invalid reserve: ${translateReservePubError( + result.body.error, + i18n, + )}`; + default: + assertUnreachable(result.body); + } + } + case "x-taler-bank": { + switch (result.body.pos) { + case 0: + return i18n.str`Invalid host`; + case 1: + return i18n.str`Invalid account`; + default: + assertUnreachable(result.body); + } + } + case "taler-reserve": { + switch (result.body.pos) { + case 0: + return i18n.str`Invalid host`; + case 1: + return i18n.str`Invalid reserve: ${translateReservePubError( + result.body.error, + i18n, + )}`; + default: + assertUnreachable(result.body); + } + } + case "taler-reserve-http": { + switch (result.body.pos) { + case 0: + return i18n.str`Invalid host`; + case 1: + return i18n.str`Invalid reserve: ${translateReservePubError( + result.body.error, + i18n, + )}`; + default: + assertUnreachable(result.body); + } + } + case "ethereum": { + switch (result.body.pos) { + case 0: + return i18n.str`Invalid address`; + default: + assertUnreachable(result.body); + } + } + } + } + } +} + +function validatePayto(s: string, i18n: InternationalizationAPI) { + const result = Paytos.fromString(s); + if (result.type === "ok") return undefined; + return translatePaytoError(result, i18n); +} + const genericFields: ( i18n: InternationalizationAPI, ) => UIFormElementConfig[] = (i18n) => [ @@ -553,11 +719,7 @@ const genericFields: ( help: i18n.str`As defined by RFC 8905`, placeholder: i18n.str`payto://`, validator(value) { - return !value - ? i18n.str`required` - : parsePaytoUri(value) === undefined - ? i18n.str`invalid` - : undefined; + return !value ? i18n.str`Required` : validatePayto(value, i18n); }, }, ]; @@ -587,11 +749,11 @@ const walletFields: (i18n: InternationalizationAPI) => UIFormElementConfig[] = ( placeholder: i18n.str`exchange.taler.net`, validator(value) { return !value - ? i18n.str`required` - : !DOMAIN_REGEX.test(value) - ? i18n.str`Invalid hostname` - : value.endsWith("/") - ? i18n.str`remove last '/'` + ? i18n.str`Required` + : value.endsWith("/") + ? i18n.str`Remove last '/'` + : !Paytos.parseHostPortPath(value) + ? i18n.str`Invalid value` : undefined; }, }, @@ -604,10 +766,12 @@ const walletFields: (i18n: InternationalizationAPI) => UIFormElementConfig[] = ( placeholder: i18n.str`abcdef1235`, validator(value) { return !value - ? i18n.str`required` + ? i18n.str`Required` : value.length !== 52 ? i18n.str`Should be 52 characters` - : undefined; + : Paytos.parseReservePub(value).type === "fail" + ? i18n.str`Invalid value` + : undefined; }, }, ]; @@ -632,10 +796,12 @@ const talerBankFields: ( placeholder: i18n.str`bank.demo.taler.net`, validator: (value) => !value - ? i18n.str`required` - : !DOMAIN_REGEX.test(value) - ? i18n.str`Invalid hostname` - : undefined, + ? i18n.str`Required` + : value.endsWith("/") + ? i18n.str`Remove last '/'` + : !Paytos.parseHostPortPath(value) + ? i18n.str`Invalid hostname` + : undefined, }, ]; @@ -644,28 +810,11 @@ function validateIBAN( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString | undefined { if (!iban) { - return i18n.str`required`; + return i18n.str`Required`; } - const result = validateIban(iban); - if (result.type === "valid") { + const result = parseIban(iban); + if (result.type === "ok") { return undefined; } - switch (result.code) { - case IbanError.TOO_LONG: - return i18n.str`IBANs usually have fewer than 34 digits.`; - case IbanError.TOO_SHORT: - return i18n.str`IBANs usually have more than 4 digits.`; - case IbanError.INVALID_CHARSET: - return i18n.str`The IBAN is invalid because it should only contain numbers and letters.`; - case IbanError.INVALID_COUNTRY: - return i18n.str`Unsupported country.`; - case IbanError.INVALID_CHECKSUM: - return i18n.str`The IBAN is invalid because the checksum is wrong.`; - default: { - assertUnreachable(result.code); - } - } + return translateIbanError(result, i18n); } - -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-.]+)*\/?$/; diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -32,7 +32,7 @@ const TALER_SCREEN_ID = 106; * @param param0 * @returns */ -export function Justification({ newPayto }: { newPayto?: PaytoString }): VNode { +export function Justification({ newPayto }: { newPayto?: string }): VNode { const isNewAccount = !!newPayto; const { i18n } = useTranslationContext(); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx @@ -66,8 +66,6 @@ export function Properties(): VNode { dialect, ); - console.log(`calculated props`, calculatedProps); - const merged = Object.entries(calculatedProps).reduce( (prev, [key, value]) => { if (prev[key] === undefined && value !== undefined) { diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -134,7 +134,7 @@ function findRuleInconsistency( * @param param0 * @returns */ -export function Rules({ newPayto }: { newPayto?: PaytoString }): VNode { +export function Rules({ newPayto }: { newPayto?: string }): VNode { const { i18n } = useTranslationContext(); const { config } = useExchangeApiContext(); const [request] = useCurrentDecisionRequest(); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -68,7 +68,7 @@ export function Summary({ newPayto, }: { account?: string; - newPayto?: PaytoString; + newPayto?: string; onMove: (n: WizardSteps | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); diff --git a/packages/bank-ui/src/hooks/account.ts b/packages/bank-ui/src/hooks/account.ts @@ -217,8 +217,8 @@ export function usePublicAccounts( } // TODO: consider sending this to web-util -export function buildPaginatedResult<DataType, OffsetId>( - data: DataType[], +export function buildPaginatedResult<DataType,OffsetId>( + data: readonly DataType[], offset: OffsetId | undefined, setOffset: (o: OffsetId | undefined) => void, getId: (r: DataType) => OffsetId, @@ -226,7 +226,7 @@ export function buildPaginatedResult<DataType, OffsetId>( const isLastPage = data.length < PAGINATED_LIST_REQUEST; const isFirstPage = offset === undefined; - const result = structuredClone(data); + const result = structuredClone(data as DataType[]); if (result.length == PAGINATED_LIST_REQUEST) { //do now show the last element, used to know if this is the last page result.pop(); diff --git a/packages/bank-ui/src/hooks/preferences.ts b/packages/bank-ui/src/hooks/preferences.ts @@ -37,7 +37,6 @@ interface Preferences { showInstallWallet: boolean; allowsSimplePassword: boolean; fastWithdrawalForm: boolean; - showCopyAccount: boolean; } export const codecForPreferences = (): Codec<Preferences> => @@ -47,7 +46,6 @@ export const codecForPreferences = (): Codec<Preferences> => .property("showInstallWallet", codecForBoolean()) .property("allowsSimplePassword", codecForBoolean()) .property("fastWithdrawalForm", codecForBoolean()) - .property("showCopyAccount", codecForBoolean()) .build("Preferences"); const defaultPreferences: Preferences = { @@ -56,7 +54,6 @@ const defaultPreferences: Preferences = { allowsSimplePassword: false, showInstallWallet: true, fastWithdrawalForm: false, - showCopyAccount: false, }; const BANK_PREFERENCES_KEY = buildStorageKey( @@ -93,14 +90,12 @@ export function getAllBooleanPreferences( "showInstallWallet", "showWithdrawalSuccess", "fastWithdrawalForm", - "showCopyAccount", ]; } return [ "showInstallWallet", "showWithdrawalSuccess", "fastWithdrawalForm", - "showCopyAccount", ]; } @@ -113,8 +108,6 @@ export function getLabelForPreferences( return i18n.str`Show withdrawal confirmation`; case "fastWithdrawalForm": return i18n.str`Withdraw without setting amount`; - case "showCopyAccount": - return i18n.str`Show copy account letter`; case "hideDemo": return i18n.str`Hide demo hint.`; case "showInstallWallet": diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -14,13 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - AbsoluteTime, - AccountLetter, HttpStatusCode, TalerCorebankApi, TalerError, TalerErrorCode, - TranslatedString, assertUnreachable, parsePaytoUri, } from "@gnu-taler/taler-util"; @@ -36,7 +33,6 @@ import { notifyInfo, useBankCoreApiContext, useChallengeHandler, - useLocalNotification, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -44,12 +40,11 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useAccountDetails } from "../../hooks/account.js"; -import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { LoggedIn, useSessionState } from "../../hooks/session.js"; +import { AccountForm } from "../admin/AccountForm.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; -import { AccountForm } from "../admin/AccountForm.js"; import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 118; @@ -170,13 +165,6 @@ export function ShowAccountDetails({ revenueURL.password; const ac = parsePaytoUri(result.body.payto_uri); const payto = !ac?.isKnown ? undefined : ac; - const accountLetter: AccountLetter | undefined = !payto - ? undefined - : { - accountURI: result.body.payto_uri, - infoURL: revenueURL.href, - accountToken: creds?.token, - }; if (mfa.pendingChallenge && repeatUpdate) { return ( @@ -523,19 +511,6 @@ export function ShowAccountDetails({ <i18n.Translate>Cancel</i18n.Translate> </a> <span></span> - - {!preferences.showCopyAccount ? ( - <span /> - ) : ( - <CopyButton - 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> - </CopyButton> - )} </div> </div> )} diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -20,8 +20,6 @@ */ import { - AccountLetter, - codecForAccountLetter, PaytoString, PaytoUri, stringifyPaytoUri, @@ -230,90 +228,6 @@ 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; - parsingError = e instanceof Error ? e.message : String(e); - } - const errors: FormErrors<{ letter: string }> = { - letter: !letter - ? i18n.str`Required` - : parsed === undefined - ? i18n.str`This letter must be a JSON string` - : account === undefined - ? i18n.str`This 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" style={{ fontSize: 16 }}> - {errors.letter} - </p> - )} - {parsingError !== undefined && ( - <p class="help is-danger" style={{ fontSize: 16 }}> - {parsingError} - </p> - )} - </div> - </div> - </div> - </ConfirmModal> - ); -} - interface CompareAccountsModalProps { onCancel: () => void; onConfirm: (account: PaytoString) => 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 @@ -41,10 +41,7 @@ import { Input } from "../../../../components/form/Input.js"; import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputToggle } from "../../../../components/form/InputToggle.js"; -import { - CompareAccountsModal, - ImportingAccountModal, -} from "../../../../components/modal/index.js"; +import { CompareAccountsModal } from "../../../../components/modal/index.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { safeConvertURL } from "../update/UpdatePage.js"; import { TestRevenueErrorType, testRevenueAPI } from "./index.js"; @@ -67,7 +64,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { ? ["none", "basic", "bearer"] : ["none", "basic"]; - const [importing, setImporting] = useState(false); const [state, setState] = useState<Partial<Entity>>({ credit_facade_credentials: { type: "basic", @@ -316,15 +312,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </FormProvider> <div class="buttons is-right mt-5"> - <button - class="button is-info" - onClick={() => { - setImporting(true); - }} - > - <i18n.Translate>Import from bank</i18n.Translate> - </button> - {onBack && ( <button class="button" onClick={onBack}> <i18n.Translate>Cancel</i18n.Translate> @@ -346,34 +333,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <div class="column" /> </div> </section> - {!importing ? undefined : ( - <ImportingAccountModal - onCancel={() => { - setImporting(false); - }} - onConfirm={(ac) => { - const u = new URL(ac.infoURL); - const user = u.username; - const pwd = u.password; - u.password = ""; - u.username = ""; - const credit_facade_url = u.href; - setState({ - payto_uri: ac.accountURI, - credit_facade_credentials: - user || pwd - ? { - type: "basic", - password: pwd, - username: user, - } - : undefined, - credit_facade_url, - }); - setImporting(false); - }} - /> - )} {!revenuePayto ? undefined : ( <CompareAccountsModal onCancel={() => { 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 @@ -21,14 +21,15 @@ import { parsePaytoUri, + Paytos, PaytoType, PaytoUri, + succeedOrThrow, TalerMerchantApi, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; -import { paytoDisplayAccountName } from "../update/UpdatePage.js"; type Entity = TalerMerchantApi.BankAccountEntry; @@ -169,7 +170,10 @@ function Table({ accounts, onDelete, onSelect }: TableProps): VNode { onClick={(): void => onSelect(acc)} style={{ cursor: "pointer" }} > - {paytoDisplayAccountName(acc.payto_uri)} + { + succeedOrThrow(Paytos.fromString(acc.payto_uri)) + .displayName + } </td> <td onClick={(): void => onSelect(acc)} @@ -204,7 +208,10 @@ function Table({ accounts, onDelete, onSelect }: TableProps): VNode { onClick={(): void => onSelect(acc)} style={{ cursor: "pointer" }} > - {paytoDisplayAccountName(acc.payto_uri)} + { + succeedOrThrow(Paytos.fromString(acc.payto_uri)) + .displayName + } </td> <td onClick={(): void => onSelect(acc)} @@ -239,7 +246,10 @@ function Table({ accounts, onDelete, onSelect }: TableProps): VNode { onClick={(): void => onSelect(acc)} style={{ cursor: "pointer" }} > - {paytoDisplayAccountName(acc.payto_uri)} + { + succeedOrThrow(Paytos.fromString(acc.payto_uri)) + .displayName + } </td> <td onClick={(): void => onSelect(acc)} @@ -274,7 +284,10 @@ function Table({ accounts, onDelete, onSelect }: TableProps): VNode { onClick={(): void => onSelect(acc)} style={{ cursor: "pointer" }} > - {paytoDisplayAccountName(acc.payto_uri)} + { + succeedOrThrow(Paytos.fromString(acc.payto_uri)) + .displayName + } </td> <td onClick={(): void => onSelect(acc)} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx @@ -23,11 +23,13 @@ import { HttpStatusCode, PaytoString, PaytoUri, + Paytos, TalerError, TalerMerchantApi, TranslatedString, assertUnreachable, parsePaytoUri, + succeedOrThrow, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -40,15 +42,12 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; +import { CompareAccountsModal } from "../../../../components/modal/index.js"; import { WithId } from "../../../../declaration.js"; +import { usePreference } from "../../../../hooks/preference.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { testRevenueAPI, TestRevenueErrorType } from "../create/index.js"; -import { InputToggle } from "../../../../components/form/InputToggle.js"; -import { - CompareAccountsModal, - ImportingAccountModal, -} from "../../../../components/modal/index.js"; -import { Preferences, usePreference } from "../../../../hooks/preference.js"; +import { TestRevenueErrorType, testRevenueAPI } from "../create/index.js"; type Entity = TalerMerchantApi.BankAccountDetail & WithId; type FormType = TalerMerchantApi.AccountPatchDetails & { @@ -86,7 +85,6 @@ export function UpdatePage({ type: "unedit", }, }); - const [importing, setImporting] = useState(false); const [revenuePayto, setRevenuePayto] = useState<PaytoUri | undefined>( // parsePaytoUri("payto://x-taler-bank/asd.com:1010/asd/pepe"), @@ -274,7 +272,9 @@ export function UpdatePage({ <div class="level-item"> <span class="is-size-4"> <i18n.Translate>Account:</i18n.Translate>{" "} - <b>{paytoDisplayAccountName(account.payto_uri)}</b> + <b> + {succeedOrThrow(Paytos.fromString(account.payto_uri)).displayName} + </b> </span> </div> </div> @@ -401,34 +401,6 @@ export function UpdatePage({ </div> </section> </section> - {!importing ? undefined : ( - <ImportingAccountModal - onCancel={() => { - setImporting(false); - }} - onConfirm={(ac) => { - const u = new URL(ac.infoURL); - const user = u.username; - const pwd = u.password; - u.password = ""; - u.username = ""; - const credit_facade_url = u.href; - setState({ - payto_uri: ac.accountURI, - credit_facade_credentials: - user || pwd - ? { - type: "basic", - password: pwd, - username: user, - } - : undefined, - credit_facade_url, - }); - setImporting(false); - }} - /> - )} {!revenuePayto ? undefined : ( <CompareAccountsModal onCancel={() => { @@ -460,32 +432,3 @@ export function safeConvertURL(s?: string): URL | undefined { return undefined; } } - -export function paytoDisplayAccountName(account: PaytoString): string { - const p = parsePaytoUri(account); - if (!p) return ""; - if (!p.isKnown) return account; - switch (p.targetType) { - case "iban": { - return p.iban; - } - case "taler-reserve": { - return `${p.reservePub}@${p.exchange}`; - } - case "taler-reserve-http": { - return `${p.reservePub}@${p.exchange}`; - } - case "x-taler-bank": { - return `${p.account}@${p.host}`; - } - case "bitcoin": { - return p.address; - } - case "ethereum": { - return p.address; - } - default: { - assertUnreachable(p); - } - } -} diff --git a/packages/taler-harness/src/env1.ts b/packages/taler-harness/src/env1.ts @@ -17,7 +17,7 @@ /** * Imports. */ -import { URL } from "@gnu-taler/taler-util"; +import { PaytoString, URL } from "@gnu-taler/taler-util"; import { CoinConfig, defaultCoinConfig } from "./harness/denomStructures.js"; import { GlobalTestState, @@ -55,7 +55,7 @@ export async function runEnv1(t: GlobalTestState): Promise<void> { password: "password", }, wireGatewayApiBaseUrl: new URL("/exchange/", bank.baseUrl).href, - accountPaytoUri: "payto://x-taler-bank/localhost/exchange", + accountPaytoUri: "payto://x-taler-bank/localhost/exchange" as PaytoString, }); await bank.start(); diff --git a/packages/taler-harness/src/harness/harness.ts b/packages/taler-harness/src/harness/harness.ts @@ -711,7 +711,7 @@ export type HarnessAccountRestriction = | [RestrictionFlag, "regex", string, string, string]; export interface HarnessExchangeBankAccount { - accountPaytoUri: string; + accountPaytoUri: PaytoString; wireGatewayApiBaseUrl: string; wireGatewayAuth: TalerWireGatewayAuth; conversionUrl?: string; @@ -2815,9 +2815,9 @@ export function generateRandomTestIban(salt: string | null = null): string { return `DE${check_digits}${bban}`; } -export function getTestHarnessPaytoForLabel(label: string): string { +export function getTestHarnessPaytoForLabel(label: string): PaytoString { // FIXME: This should also support iban! - return `payto://x-taler-bank/localhost/${label}?receiver-name=${label}`; + return `payto://x-taler-bank/localhost/${label}?receiver-name=${label}` as PaytoString; } export function waitMs(tMs: number): Promise<void> { diff --git a/packages/taler-harness/src/index.ts b/packages/taler-harness/src/index.ts @@ -32,6 +32,7 @@ import { LoginTokenScope, MerchantAuthMethod, PaytoString, + Paytos, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerExchangeHttpClient, @@ -1237,7 +1238,9 @@ deploymentCli const bankURL = args.provisionMerchantInstance.bankURL; const bankUser = args.provisionMerchantInstance.bankUser; const bankPassword = args.provisionMerchantInstance.bankPassword; - const accountPayto = args.provisionMerchantInstance.payto; + const accountPayto = Paytos.toNormalizedString( + succeedOrThrow(Paytos.fromString(args.provisionMerchantInstance.payto)), + ); let defaultWireTransferDelay: Duration; if (args.provisionMerchantInstance.defaultWireTransferDelay) { @@ -1410,11 +1413,13 @@ deploymentCli // Strip question mark part from payto URI, // as libeufin wants a plain payto here. - let payto = args.provisionBankAccount.internalPayto; - let idxQm = payto?.indexOf("?"); - if (payto && idxQm != null && idxQm >= 0) { - payto = payto.substring(0, idxQm); - } + const payto_uri = !args.provisionBankAccount.internalPayto + ? undefined + : Paytos.toNormalizedString( + succeedOrThrow( + Paytos.fromString(args.provisionBankAccount.internalPayto), + ), + ); const accountLogin = args.provisionBankAccount.login; const resp = await api.createAccount(undefined, { @@ -1423,7 +1428,7 @@ deploymentCli username: accountLogin, is_public: !!args.provisionBankAccount.public, is_taler_exchange: !!args.provisionBankAccount.exchange, - payto_uri: args.provisionBankAccount.internalPayto, + payto_uri, }); if (resp.type === "ok") { diff --git a/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-rewrite.ts b/packages/taler-harness/src/integrationtests/test-kyc-merchant-deposit-rewrite.ts @@ -25,6 +25,7 @@ import { Logger, MerchantAccountKycRedirectsResponse, MerchantAccountKycStatus, + PaytoString, succeedOrThrow, } from "@gnu-taler/taler-util"; import { @@ -116,7 +117,7 @@ export async function runKycMerchantDepositRewriteTest(t: GlobalTestState) { }); let exchangeWireTarget: string | undefined; - let merchantBankAccount: string | undefined; + let merchantBankAccount: PaytoString | undefined; { const kycStatus = await retryUntil( async () => { diff --git a/packages/taler-harness/src/integrationtests/test-merchant-acctsel.ts b/packages/taler-harness/src/integrationtests/test-merchant-acctsel.ts @@ -20,6 +20,7 @@ import { Duration, j2s, + PaytoString, succeedOrThrow, TalerMerchantInstanceHttpClient, } from "@gnu-taler/taler-util"; @@ -52,10 +53,10 @@ export async function runMerchantAcctselTest(t: GlobalTestState) { exchange1.addCoinConfigList(defaultCoinConfig.map((x) => x("TESTKUDOS"))); const paytoXtb = - "payto://x-taler-bank/localhost/exchange?receiver-name=exchange"; + "payto://x-taler-bank/localhost/exchange?receiver-name=exchange" as PaytoString; const paytoIban = - "payto://iban/DE76500202009817493529?receiver-name=exchange"; + "payto://iban/DE76500202009817493529?receiver-name=exchange" as PaytoString; await exchange1.addBankAccount("1", { accountPaytoUri: paytoXtb, diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-fakebank.ts @@ -17,7 +17,7 @@ /** * Imports. */ -import { AmountString, URL } from "@gnu-taler/taler-util"; +import { AmountString, PaytoString, URL } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; import { @@ -61,7 +61,7 @@ export async function runWithdrawalFakebankTest(t: GlobalTestState) { bank.baseUrl, ).href, accountPaytoUri: - "payto://x-taler-bank/localhost/exchange?receiver-name=Exchange", + "payto://x-taler-bank/localhost/exchange?receiver-name=Exchange" as PaytoString, }); await bank.createExchangeAccount("exchange", "password"); diff --git a/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts b/packages/taler-harness/src/integrationtests/test-withdrawal-huge.ts @@ -20,6 +20,7 @@ import { AmountString, NotificationType, + PaytoString, TalerCorebankApiClient, TransactionMajorState, URL, @@ -61,7 +62,7 @@ export async function runWithdrawalHugeTest(t: GlobalTestState) { database: db.connStr, }); - let paytoUri = "payto://x-taler-bank/localhost/exchange"; + let paytoUri = "payto://x-taler-bank/localhost/exchange" as PaytoString; await exchange.addBankAccount("1", { wireGatewayAuth: { diff --git a/packages/taler-util/src/bank-api-client.ts b/packages/taler-util/src/bank-api-client.ts @@ -66,7 +66,7 @@ export interface BankAccountBalanceResponse { export interface BankUser { username: string; password: string; - accountPaytoUri: string; + accountPaytoUri: PaytoString; } export interface WithdrawalOperationInfo { diff --git a/packages/taler-util/src/bech32.ts b/packages/taler-util/src/bech32.ts @@ -18,31 +18,18 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. +import { + assertUnreachable, + BtAddrString, + OperationResult, + opFixedSuccess, + opKnownFailure +} from "./index.js"; + var CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; var GENERATOR = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; -const encodings: any = { - BECH32: "bech32", - BECH32M: "bech32m", -}; - -export default { - decode: decode, - encode: encode, - encodings: encodings, -}; - -function getEncodingConst(enc: any) { - if (enc == encodings.BECH32) { - return 1; - } else if (enc == encodings.BECH32M) { - return 0x2bc830a3; - } else { - throw new Error("unknown encoding"); - } -} - -function polymod(values: any) { +function polymod(values: Array<number>): number { var chk = 1; for (var p = 0; p < values.length; ++p) { var top = chk >> 25; @@ -56,76 +43,141 @@ function polymod(values: any) { return chk; } -function hrpExpand(hrp: any) { - var ret = []; - var p; - for (p = 0; p < hrp.length; ++p) { +function hrpExpand(hrp: string): Array<number> { + const ret: Array<number> = []; + for (let p = 0; p < hrp.length; ++p) { ret.push(hrp.charCodeAt(p) >> 5); } ret.push(0); - for (p = 0; p < hrp.length; ++p) { + for (let p = 0; p < hrp.length; ++p) { ret.push(hrp.charCodeAt(p) & 31); } return ret; } -function verifyChecksum(hrp: any, data: any, enc: any) { +function getEncodingConst(enc: BitcoinBech32.Encodings): number { + switch (enc) { + case BitcoinBech32.Encodings.BECH32: + return 1; + case BitcoinBech32.Encodings.BECH32M: + return 0x2bc830a3; + + default: { + assertUnreachable(enc); + } + } +} + +function verifyChecksum( + hrp: string, + data: Array<number>, + enc: BitcoinBech32.Encodings, +): boolean { return polymod(hrpExpand(hrp).concat(data)) === getEncodingConst(enc); } -function createChecksum(hrp: any, data: any, enc: any) { - var values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]); - var mod = polymod(values) ^ getEncodingConst(enc); - var ret = []; - for (var p = 0; p < 6; ++p) { +function createChecksum( + hrp: string, + data: Array<number>, + enc: BitcoinBech32.Encodings, +): Array<number> { + const values = hrpExpand(hrp).concat(data).concat([0, 0, 0, 0, 0, 0]); + const mod = polymod(values) ^ getEncodingConst(enc); + const ret: Array<number> = []; + for (let p = 0; p < 6; ++p) { ret.push((mod >> (5 * (5 - p))) & 31); } return ret; } -function encode(hrp: any, data: any, enc: any): string { - var combined = data.concat(createChecksum(hrp, data, enc)); - var ret = hrp + "1"; - for (var p = 0; p < combined.length; ++p) { - ret += CHARSET.charAt(combined[p]); +export namespace BitcoinBech32 { + + export enum Encodings { + BECH32 = "bech32", + BECH32M = "bech32m", + }; + + export function encode( + hrp: string, + data: Array<number>, + enc: Encodings, + ): BtAddrString { + var combined = data.concat(createChecksum(hrp, data, enc)); + var ret = hrp + "1"; + for (var p = 0; p < combined.length; ++p) { + ret += CHARSET.charAt(combined[p]); + } + return ret as BtAddrString; + } + + export enum BitcoinParseError { + /** + * Charset can only be from BECH32 + */ + WRONG_CHARSET, + /** + * All uppercased or all lowercased + */ + MIXING_UPPER_AND_LOWER, + /** + * Separator is a '1' between addr and addrtype + */ + MISSING_HRP, + /** + * Should be less or equal to 90 chars + */ + TOO_LONG, + /** + * Addr should be greather or equal to 6 chars + */ + TOO_SHORT, + WRONG_CHECKSUM, } - return ret; -} -function decode(bechString: any, enc: any) { - var p; - var has_lower = false; - var has_upper = false; - for (p = 0; p < bechString.length; ++p) { - if (bechString.charCodeAt(p) < 33 || bechString.charCodeAt(p) > 126) { - return null; + export function decode( + bechString: string, + enc?: Encodings, + ) { + let p; + let has_lower = false; + let has_upper = false; + for (p = 0; p < bechString.length; ++p) { + if (bechString.charCodeAt(p) < 33 || bechString.charCodeAt(p) > 126) { + return opKnownFailure(BitcoinParseError.WRONG_CHARSET); + } + if (bechString.charCodeAt(p) >= 97 && bechString.charCodeAt(p) <= 122) { + has_lower = true; + } + if (bechString.charCodeAt(p) >= 65 && bechString.charCodeAt(p) <= 90) { + has_upper = true; + } } - if (bechString.charCodeAt(p) >= 97 && bechString.charCodeAt(p) <= 122) { - has_lower = true; + if (has_lower && has_upper) { + return opKnownFailure(BitcoinParseError.MIXING_UPPER_AND_LOWER); } - if (bechString.charCodeAt(p) >= 65 && bechString.charCodeAt(p) <= 90) { - has_upper = true; + bechString = bechString.toLowerCase(); + const pos = bechString.lastIndexOf("1"); + if (pos < 1) { + return opKnownFailure(BitcoinParseError.MISSING_HRP); } - } - if (has_lower && has_upper) { - return null; - } - bechString = bechString.toLowerCase(); - var pos = bechString.lastIndexOf("1"); - if (pos < 1 || pos + 7 > bechString.length || bechString.length > 90) { - return null; - } - var hrp = bechString.substring(0, pos); - var data = []; - for (p = pos + 1; p < bechString.length; ++p) { - var d = CHARSET.indexOf(bechString.charAt(p)); - if (d === -1) { - return null; + if (pos + 7 > bechString.length) { + return opKnownFailure(BitcoinParseError.TOO_SHORT); } - data.push(d); - } - if (!verifyChecksum(hrp, data, enc)) { - return null; + if (bechString.length > 90) { + return opKnownFailure(BitcoinParseError.TOO_LONG); + } + const hrp = bechString.substring(0, pos); + var data: Array<number> = []; + for (p = pos + 1; p < bechString.length; ++p) { + var d = CHARSET.indexOf(bechString.charAt(p)); + if (d === -1) { + return opKnownFailure(BitcoinParseError.WRONG_CHARSET); + } + data.push(d); + } + if (enc && !verifyChecksum(hrp, data, enc)) { + return opKnownFailure(BitcoinParseError.WRONG_CHECKSUM); + } + return opFixedSuccess({ hrp, data: data.slice(0, data.length - 6) }); } - return { hrp: hrp, data: data.slice(0, data.length - 6) }; } diff --git a/packages/taler-util/src/bitcoin.test.ts b/packages/taler-util/src/bitcoin.test.ts @@ -20,11 +20,16 @@ import test from "ava"; import { generateFakeSegwitAddress } from "./bitcoin.js"; +import { Paytos, succeedOrThrow } from "./index.node.js"; test("generate testnet", (t) => { - const [addr1, addr2] = generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG", - "tb1qhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + const [addr1, addr2] = succeedOrThrow( + generateFakeSegwitAddress( + succeedOrThrow( + Paytos.parseReservePub("B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG"), + ), + "tb1qhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + ), ); t.assert(addr1 === "tb1qtfwqwaj6tsrhdtvuyhflr6nklm8ldqxpf0lfjw"); @@ -32,9 +37,13 @@ test("generate testnet", (t) => { }); test("generate mainnet", (t) => { - const [addr1, addr2] = generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG", - "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + const [addr1, addr2] = succeedOrThrow( + generateFakeSegwitAddress( + succeedOrThrow( + Paytos.parseReservePub("B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG"), + ), + "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + ), ); //bc t.assert(addr1 === "bc1qtfwqwaj6tsrhdtvuyhflr6nklm8ldqxprfy6fa"); @@ -42,9 +51,13 @@ test("generate mainnet", (t) => { }); test("generate Regtest", (t) => { - const [addr1, addr2] = generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + const [addr1, addr2] = succeedOrThrow( + generateFakeSegwitAddress( + succeedOrThrow( + Paytos.parseReservePub("B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG"), + ), + "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + ), ); t.assert(addr1 === "bcrt1qtfwqwaj6tsrhdtvuyhflr6nklm8ldqxptxxy98"); @@ -53,56 +66,104 @@ test("generate Regtest", (t) => { test("unknown net", (t) => { t.throws(() => { - generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG", - "abqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + succeedOrThrow( + generateFakeSegwitAddress( + succeedOrThrow( + Paytos.parseReservePub( + "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSG", + ), + ), + "abqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + ), ); }); }); test("invalid or no reserve", (t) => { let result = undefined; + { + const result = Paytos.parseReservePub(""); + t.assert(result.type === "fail"); + } // empty - result = generateFakeSegwitAddress( - "", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); + // result = generateFakeSegwitAddress( + // "", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub("s"); + t.assert(result.type === "fail"); + } // small - result = generateFakeSegwitAddress( - "s", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); - result = generateFakeSegwitAddress( - "asdsad", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); - result = generateFakeSegwitAddress( - "asdasdasdasdasdasd", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); - result = generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); - result = generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSSSS", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); + // result = generateFakeSegwitAddress( + // "s", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub("asdsad"); + t.assert(result.type === "fail"); + } + + // result = generateFakeSegwitAddress( + // "asdsad", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub("asdasdasdasdasdasd"); + t.assert(result.type === "fail"); + } + + // result = generateFakeSegwitAddress( + // "asdasdasdasdasdasd", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub( + "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS", + ); + t.assert(result.type === "fail"); + } + + // result = generateFakeSegwitAddress( + // "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub( + "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSSSS", + ); + t.assert(result.type === "fail"); + } + + // result = generateFakeSegwitAddress( + // "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XSSSS", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub(undefined); + t.assert(result.type === "fail"); + } + // no reserve - result = generateFakeSegwitAddress( - undefined, - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); - result = generateFakeSegwitAddress( - "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS-", - "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", - ); - t.deepEqual(result, []); + // result = generateFakeSegwitAddress( + // undefined, + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); + { + const result = Paytos.parseReservePub("B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS-"); + t.assert(result.type === "fail"); + } + + // result = generateFakeSegwitAddress( + // "B9E0EXNDKGJX7WFAEVZCZXM0R661T66YWD71N7NRFDEWQEV10XS-", + // "bcrtqhxrhccqexg0dv4nltgkuw4fg2ce7muplmjsn0v", + // ); + // t.deepEqual(result, []); }); diff --git a/packages/taler-util/src/bitcoin.ts b/packages/taler-util/src/bitcoin.ts @@ -23,8 +23,13 @@ * Imports. */ import { AmountJson, Amounts } from "./amounts.js"; -import { decodeCrock } from "./taler-crypto.js"; -import * as segwit from "./segwit_addr.js"; +import { + BtAddrString, + OperationResult, + opFixedSuccess, + opKnownFailure +} from "./index.js"; +import { BitcoinSewgit } from "./segwit_addr.js"; function buf2hex(buffer: Uint8Array) { // buffer is an ArrayBuffer @@ -36,19 +41,25 @@ function buf2hex(buffer: Uint8Array) { const hext2buf = (hexString: string) => new Uint8Array(hexString.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16))); +export enum GenerateSegwitAddrError { + /** + * The reserve pub used to generate the segwith is invalid + */ + WRONG_RESERVE_PUB = "wrong-reserve-pub", + /** + * The net prefix of the addr is unsupported + */ + WRONG_PREFIX = "wrong-prefix", + /** + * The process of generating a segwit failed + */ + INVALID_SEGWIT = "invalid-segwit", +} + export function generateFakeSegwitAddress( - reservePub: string | undefined, + pub: Uint8Array, addr: string, -): string[] { - if (!reservePub) return []; - let pub; - try { - pub = decodeCrock(reservePub); - } catch { - // pub = new Uint8Array(0) - } - if (!pub || pub.length !== 32) return []; - +): OperationResult<[BtAddrString, BtAddrString], GenerateSegwitAddrError> { const first_rnd = new Uint8Array(4); first_rnd.set(pub.subarray(0, 4)); const second_rnd = new Uint8Array(4); @@ -73,12 +84,20 @@ export function generateFakeSegwitAddress( : addr[0] === "b" && addr[1] == "c" ? "bc" : undefined; - if (prefix === undefined) throw new Error("unknown bitcoin net"); - - const addr1 = segwit.default.encode(prefix, 0, first_part); - const addr2 = segwit.default.encode(prefix, 0, second_part); + if (prefix === undefined) { + return opKnownFailure(GenerateSegwitAddrError.WRONG_PREFIX); + } - return [addr1, addr2]; + const addr1 = BitcoinSewgit.encode(prefix, 0, Array.from(first_part)); + if (addr1.type === "fail") { + return opKnownFailure(GenerateSegwitAddrError.INVALID_SEGWIT); + } + const addr2 = BitcoinSewgit.encode(prefix, 0, Array.from(second_part)); + if (addr2.type === "fail") { + return opKnownFailure(GenerateSegwitAddrError.INVALID_SEGWIT); + } + const result: [BtAddrString, BtAddrString] = [addr1.body, addr2.body]; + return opFixedSuccess(result); } // https://github.com/bitcoin/bitcoin/blob/master/src/policy/policy.cpp diff --git a/packages/taler-util/src/iban.ts b/packages/taler-util/src/iban.ts @@ -14,6 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { OperationResult, opFixedSuccess, opKnownFailure } from "./index.js"; + /** * IBAN validation. * @@ -26,18 +28,40 @@ * The country list is also not complete. * * @author Florian Dold <dold@taler.net> + * @author sebasjm */ -export enum IbanError { - INVALID_COUNTRY, +declare const __iban: unique symbol; +export type IbanString = string & { [__iban]: true }; + +export enum ParseIbanError { + /** + * The country is not the one listed in https://www.swift.com/resource/iban-registry-pdf + */ + UNSUPPORTED_COUNTRY, + /** + * The IBAN length should be less than 34 chars + */ TOO_LONG, + /** + * The IBAN length should be greater than 4 chars + */ TOO_SHORT, + /** + * The IBAN should only have letters and numbers + */ INVALID_CHARSET, + /** + * Computed MOD-97-10 checksum doesn't match + */ INVALID_CHECKSUM, } +/** + * @deprecated + */ export type IbanValidationResult = - | { type: "invalid"; code: IbanError } + | { type: "invalid"; code: ParseIbanError } | { type: "valid"; normalizedIban: string; @@ -49,6 +73,183 @@ export interface IbanCountryInfo { length?: number; } +const ccZero = "0".charCodeAt(0); +const ccNine = "9".charCodeAt(0); +const ccA = "A".charCodeAt(0); +const ccZ = "Z".charCodeAt(0); + +/** + * Append a IBAN digit(s) based on a char code. + */ +function appendDigit(digits: number[], cc: number): boolean { + if (cc >= ccZero && cc <= ccNine) { + digits.push(cc - ccZero); + } else if (cc >= ccA && cc <= ccZ) { + const n = cc - ccA + 10; + digits.push(Math.floor(n / 10) % 10); + digits.push(n % 10); + } else { + return false; + } + return true; +} + +/** + * Compute MOD-97-10 as per ISO/IEC 7064:2003. + */ +function mod97(digits: number[]): number { + let i = 0; + let modAccum = 0; + while (i < digits.length) { + let n = 0; + while (n < 9 && i < digits.length) { + modAccum = modAccum * 10 + digits[i]; + i++; + n++; + } + modAccum = modAccum % 97; + } + return modAccum; +} + +/** + * Check the IBAN is correct and return canonical form + * @param ibanString + * @returns + */ +export function parseIban( + ibanString: string, +): OperationResult<IbanString, ParseIbanError> { + if (ibanString.length < 4) { + return opKnownFailure(ParseIbanError.TOO_SHORT); + } + if (ibanString.length > 34) { + return opKnownFailure(ParseIbanError.TOO_LONG); + } + + const myIban = ibanString.toUpperCase().replace(/[\s-\._]/g, ""); + const countryCode = myIban.substring(0, 2); + const countryInfo = ibanCountryInfoTable[countryCode]; + + if (!countryInfo) { + return opKnownFailure(ParseIbanError.UNSUPPORTED_COUNTRY); + } + + let digits: number[] = []; + + for (let i = 4; i < myIban.length; i++) { + const cc = myIban.charCodeAt(i); + if (!appendDigit(digits, cc)) { + return opKnownFailure(ParseIbanError.INVALID_CHARSET); + } + } + + for (let i = 0; i < 4; i++) { + const cc = myIban.charCodeAt(i); + if (!appendDigit(digits, cc)) { + return opKnownFailure(ParseIbanError.INVALID_CHARSET); + } + } + + const rem = mod97(digits); + if (rem === 1) { + return opFixedSuccess<IbanString>(myIban as IbanString); + } else { + return opKnownFailure(ParseIbanError.INVALID_CHECKSUM); + } +} + +/** + * @deprecated use parseIban + * + * @param ibanString + * @returns + */ +export function validateIban(ibanString: string): IbanValidationResult { + if (ibanString.length < 4) { + return { + type: "invalid", + code: ParseIbanError.TOO_SHORT, + }; + } + if (ibanString.length > 34) { + return { + type: "invalid", + code: ParseIbanError.TOO_LONG, + }; + } + + const myIban = ibanString.toLocaleUpperCase().replace(" ", ""); + const countryCode = myIban.substring(0, 2); + const countryInfo = ibanCountryInfoTable[countryCode]; + + if (!countryInfo) { + return { + type: "invalid", + code: ParseIbanError.UNSUPPORTED_COUNTRY, + }; + } + + let digits: number[] = []; + + for (let i = 4; i < myIban.length; i++) { + const cc = myIban.charCodeAt(i); + if (!appendDigit(digits, cc)) { + return { + type: "invalid", + code: ParseIbanError.INVALID_CHARSET, + }; + } + } + + for (let i = 0; i < 4; i++) { + if (!appendDigit(digits, ibanString.charCodeAt(i))) { + return { + type: "invalid", + code: ParseIbanError.INVALID_CHARSET, + }; + } + } + + const rem = mod97(digits); + if (rem === 1) { + return { + type: "valid", + normalizedIban: myIban, + }; + } else { + return { + type: "invalid", + code: ParseIbanError.INVALID_CHECKSUM, + }; + } +} + +export function generateIban(countryCode: string, length: number): IbanString { + let ibanSuffix = ""; + let digits: number[] = []; + + for (let i = 0; i < length; i++) { + const cc = ccZero + (Math.floor(Math.random() * 100) % 10); + appendDigit(digits, cc); + ibanSuffix += String.fromCharCode(cc); + } + + appendDigit(digits, countryCode.charCodeAt(0)); + appendDigit(digits, countryCode.charCodeAt(1)); + + // Try using "00" as check digits + appendDigit(digits, ccZero); + appendDigit(digits, ccZero); + + const requiredChecksum = 98 - mod97(digits); + + const checkDigit1 = Math.floor(requiredChecksum / 10) % 10; + const checkDigit2 = requiredChecksum % 10; + + return (countryCode + checkDigit1 + checkDigit2 + ibanSuffix) as IbanString; +} + /** * Incomplete list, see https://www.swift.com/resource/iban-registry-pdf */ @@ -195,127 +396,3 @@ export const ibanCountryInfoTable: Record<string, IbanCountryInfo> = { ZA: { name: "South Africa" }, ZW: { name: "Zimbabwe" }, }; - -let ccZero = "0".charCodeAt(0); -let ccNine = "9".charCodeAt(0); -let ccA = "A".charCodeAt(0); -let ccZ = "Z".charCodeAt(0); - -/** - * Append a IBAN digit(s) based on a char code. - */ -function appendDigit(digits: number[], cc: number): boolean { - if (cc >= ccZero && cc <= ccNine) { - digits.push(cc - ccZero); - } else if (cc >= ccA && cc <= ccZ) { - const n = cc - ccA + 10; - digits.push(Math.floor(n / 10) % 10); - digits.push(n % 10); - } else { - return false; - } - return true; -} - -/** - * Compute MOD-97-10 as per ISO/IEC 7064:2003. - */ -function mod97(digits: number[]): number { - let i = 0; - let modAccum = 0; - while (i < digits.length) { - let n = 0; - while (n < 9 && i < digits.length) { - modAccum = modAccum * 10 + digits[i]; - i++; - n++; - } - modAccum = modAccum % 97; - } - return modAccum; -} - -export function validateIban(ibanString: string): IbanValidationResult { - if (ibanString.length < 4) { - return { - type: "invalid", - code: IbanError.TOO_SHORT, - }; - } - if (ibanString.length > 34) { - return { - type: "invalid", - code: IbanError.TOO_LONG, - }; - } - - const myIban = ibanString.toLocaleUpperCase().replace(" ", ""); - const countryCode = myIban.substring(0, 2); - const countryInfo = ibanCountryInfoTable[countryCode]; - - if (!countryInfo) { - return { - type: "invalid", - code: IbanError.INVALID_COUNTRY, - }; - } - - let digits: number[] = []; - - for (let i = 4; i < myIban.length; i++) { - const cc = myIban.charCodeAt(i); - if (!appendDigit(digits, cc)) { - return { - type: "invalid", - code: IbanError.INVALID_CHARSET, - }; - } - } - - for (let i = 0; i < 4; i++) { - if (!appendDigit(digits, ibanString.charCodeAt(i))) { - return { - type: "invalid", - code: IbanError.INVALID_CHARSET, - }; - } - } - - const rem = mod97(digits); - if (rem === 1) { - return { - type: "valid", - normalizedIban: myIban, - }; - } else { - return { - type: "invalid", - code: IbanError.INVALID_CHECKSUM, - }; - } -} - -export function generateIban(countryCode: string, length: number): string { - let ibanSuffix = ""; - let digits: number[] = []; - - for (let i = 0; i < length; i++) { - const cc = ccZero + (Math.floor(Math.random() * 100) % 10); - appendDigit(digits, cc); - ibanSuffix += String.fromCharCode(cc); - } - - appendDigit(digits, countryCode.charCodeAt(0)); - appendDigit(digits, countryCode.charCodeAt(1)); - - // Try using "00" as check digits - appendDigit(digits, ccZero); - appendDigit(digits, ccZero); - - const requiredChecksum = 98 - mod97(digits); - - const checkDigit1 = Math.floor(requiredChecksum / 10) % 10; - const checkDigit2 = requiredChecksum % 10; - - return countryCode + checkDigit1 + checkDigit2 + ibanSuffix; -} diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts @@ -2,6 +2,7 @@ export * from "./amounts.js"; export * from "./bank-api-client.js"; export * from "./base64.js"; export * from "./bitcoin.js"; +export * from "./bech32.js"; export * from "./CancellationToken.js"; export * from "./codec.js"; export * from "./contract-terms.js"; diff --git a/packages/taler-util/src/operation.ts b/packages/taler-util/src/operation.ts @@ -33,7 +33,7 @@ import { TalerErrorDetail, } from "./index.js"; -export type OperationResult<Body, ErrorEnum, K = never> = +export type OperationResult<Body, ErrorEnum> = | OperationOk<Body> | OperationAlternative<ErrorEnum, any> | OperationFail<ErrorEnum>; @@ -113,11 +113,11 @@ export function opEmptySuccess(): OperationOk<void> { return { type: "ok" as const, case: "ok", body: void 0 }; } -export function opKnownFailure<T>(case_: T): OperationFail<T> { +export function opKnownFailure<const T>(case_: T): OperationFail<T> { return { type: "fail", case: case_ }; } -export function opKnownFailureWithBody<T, B>( +export function opKnownFailureWithBody<const T, const B>( case_: T, body: B, ): OperationAlternative<T, B> { diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts @@ -14,24 +14,662 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { BitcoinBech32 } from "./bech32.js"; import { generateFakeSegwitAddress } from "./bitcoin.js"; +import { Codec, Context, DecodingError, renderContext } from "./codec.js"; import { - Codec, - Context, - DecodingError, - buildCodecForObject, - codecForStringURL, - renderContext, -} from "./codec.js"; -import { - AccessToken, - codecForAccessToken, - codecOptional, + assertUnreachable, + decodeCrock, + encodeCrock, hashTruncate32, + ParseIbanError as IbanParseError, + IbanString, + OperationResult, + opFixedSuccess, + opKnownFailure, + opKnownFailureWithBody, + parseIban, stringToBytes, } from "./index.js"; import { URLSearchParams } from "./url.js"; +/** + * @deprecated use NormalizedPayto or FullPayto + */ +export type PaytoString = string; + +const PAYTO_PREFIX = "payto://"; + +export type PaytoType = + | "iban" + | "bitcoin" + | "x-taler-bank" + | "taler-reserve" + | "taler-reserve-http" + | "ethereum"; + +export enum ReservePubParseError { + /** + * It should be 52 characters + */ + WRONG_LENGTH, + DECODE_ERROR, +} +declare const __hostport_str: unique symbol; +export type HostPortPath = string & { [__hostport_str]: true }; + +declare const __btaddr_str: unique symbol; +export type BtAddrString = string & { [__btaddr_str]: true }; +declare const __ethaddr_str: unique symbol; +export type EthAddrString = string & { [__ethaddr_str]: true }; + +export enum PaytoParseError { + /** + * Payto should start with payto:// + */ + WRONG_PREFIX, + /** + * Payto should have a / after the target type + */ + INCOMPLETE, + /** + * Target 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 or more path components failed + */ + INVALID_TARGET_PATH, +} + +export namespace Paytos { + export type URI = + | PaytoUnsupported + | PaytoIBAN + | PaytoTalerReserve + | PaytoTalerReserveHttp + | PaytoTalerBank + | PaytoEthereum + | PaytoBitcoin; + + declare const __full_payto_str: unique symbol; + export type FullPaytoString = string & { [__full_payto_str]: true }; + + declare const __norm_payto_str: unique symbol; + export type NormalizedPaytoString = string & { [__norm_payto_str]: true }; + + interface PaytoGeneric { + /** + * String after the prefix and before the first / + */ + targetType: PaytoType | "unsupported"; + /** + * String after the first / + */ + normalizedPath: string; + /** + * String after the first / + */ + fullPath: string; + /** + * Return the account identification when the target type is already known. Useful to show in the UI + */ + displayName: string; + /** + * All the URL params after the first ? + */ + params: { [name: string]: string }; + } + + export interface PaytoUnsupported extends PaytoGeneric { + targetType: "unsupported"; + target: string; + } + + export interface PaytoIBAN extends PaytoGeneric { + targetType: "iban"; + iban: IbanString; + bic?: string; + } + + export interface PaytoTalerReserve extends PaytoGeneric { + targetType: "taler-reserve"; + exchange: HostPortPath; + reservePub: Uint8Array; + } + + export interface PaytoTalerReserveHttp extends PaytoGeneric { + targetType: "taler-reserve-http"; + exchange: HostPortPath; + reservePub: Uint8Array; + } + + export interface PaytoTalerBank extends PaytoGeneric { + targetType: "x-taler-bank"; + host: HostPortPath; + account: string; + } + + export interface PaytoBitcoin extends PaytoGeneric { + targetType: "bitcoin"; + address: BtAddrString; + reservePub: Uint8Array | undefined; + segwitAddrs: Array<BtAddrString>; + } + + export interface PaytoEthereum extends PaytoGeneric { + targetType: "ethereum"; + address: EthAddrString; + } + + const supported_targets: Record<PaytoType, true> = { + iban: true, + bitcoin: true, + "x-taler-bank": true, + "taler-reserve": true, + "taler-reserve-http": true, + ethereum: true, + }; + + export function hash(p: NormalizedPaytoString | FullPaytoString): Uint8Array { + 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 + * bank account. Thus, optional arguments such as the *receiver-name* or + * optional path components such as the BIC must be removed and the account + * must be given in a canonical form for the wire method (for example, + * everything in lower-case) + * + * @param p + * @returns + */ + export function toNormalizedString(p: URI): NormalizedPaytoString { + const url = new URL(`${PAYTO_PREFIX}${p.targetType}/${p.normalizedPath}`); + return url.href as NormalizedPaytoString; + } + /** + * A **full** payto-URI is not expected to have a canonical form for + * a bank account (there can be many full payto-URIs for the same bank + * account) and must include at least the *receiver-name* but possibly also + * other (in RFC 8905 optional) arguments to identify the recipient, as + * those may be needed to do a wire transfer. + * + * @param p + * @returns + */ + export function toFullString(p: URI): FullPaytoString { + const url = new URL(`${PAYTO_PREFIX}${p.targetType}/${p.fullPath}`); + const paramList = !p.params ? [] : Object.entries(p.params); + url.search = createSearchParams(paramList); + return url.href as FullPaytoString; + } + + export function parseReservePub(reserve: string | undefined) { + if (!reserve) return opKnownFailure(ReservePubParseError.WRONG_LENGTH); + try { + const pub = decodeCrock(reserve); + if (!pub || pub.length !== 32) { + return opKnownFailure(ReservePubParseError.WRONG_LENGTH); + } + return opFixedSuccess(pub); + } catch (e) { + return opKnownFailureWithBody(ReservePubParseError.DECODE_ERROR, { + message: String(e), + }); + } + } + export function parseHostPortPath( + hostname: string, + scheme: "http" | "https" = "https", + ): HostPortPath | undefined { + try { + const url = new URL("/", `${scheme}://${hostname}`); + if (url.port) { + return `${url.host}:${url.port}` as HostPortPath; + } else { + return `${url.host}` 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 + * @param str + */ + export function parseEthereumAddress(str: String): EthAddrString | undefined { + if (!str) { + return undefined; + } + return str as EthAddrString; + } + + /** + * FIXME: add bank account name validation + * + * @param account + * @returns + */ + export function parseTalerBankAccount(account: string): string | undefined { + if (!account) { + return undefined; + } + return account; + } + ////////////////// + // function to create objs + ////////////////// + export function createUnsupported( + targetType: string, + path: string, + params: Record<string, string> = {}, + ): PaytoUnsupported { + return { + targetType: "unsupported", + target: targetType, + params, + normalizedPath: path.toLocaleLowerCase(), + fullPath: path, + displayName: path, + }; + } + export function createIbanPayto( + iban: IbanString, + bic: string | undefined, + params: Record<string, string> = {}, + ): PaytoIBAN { + return { + targetType: "iban", + iban, + bic, + params, + normalizedPath: iban.toLocaleLowerCase(), + fullPath: !bic ? iban : `${bic}/${iban}`, + displayName: iban, + }; + } + export function createBitcoinPayto( + address: BtAddrString, + reservePub: Uint8Array | undefined, + params: Record<string, string> = {}, + ): PaytoBitcoin { + const sgRes = !reservePub + ? undefined + : generateFakeSegwitAddress(reservePub, address); + + const segwitAddrs = !sgRes || sgRes.type === "fail" ? [] : sgRes.body; + return { + targetType: "bitcoin", + address, + reservePub, + segwitAddrs, + params, + normalizedPath: address.toLocaleLowerCase(), + fullPath: !reservePub ? address : `${address}/${encodeCrock(reservePub)}`, + displayName: address, + }; + } + export function createEthereum( + address: EthAddrString, + params: Record<string, string> = {}, + ): PaytoEthereum { + return { + targetType: "ethereum", + address, + params, + normalizedPath: address, + fullPath: address, + displayName: address, + }; + } + export function createTalerReserve( + exchange: HostPortPath, + reservePub: Uint8Array, + params: Record<string, string> = {}, + ): PaytoTalerReserve { + const pub = encodeCrock(reservePub); + return { + targetType: "taler-reserve", + exchange, + reservePub, + params, + normalizedPath: `${exchange.toLocaleLowerCase()}/${pub}`, + fullPath: `${exchange}/${pub}`, + displayName: `${exchange}@${pub}`, + }; + } + export function createTalerReserveHttp( + exchange: HostPortPath, + reservePub: Uint8Array, + params: Record<string, string> = {}, + ): PaytoTalerReserveHttp { + const pub = encodeCrock(reservePub); + return { + targetType: "taler-reserve-http", + exchange, + reservePub, + params, + normalizedPath: `${exchange.toLocaleLowerCase()}/${pub}`, + fullPath: `${exchange}/${pub}`, + displayName: `${exchange}@${pub}`, + }; + } + export function createTalerBank( + host: HostPortPath, + account: string, + params: Record<string, string> = {}, + ): PaytoTalerBank { + return { + targetType: "x-taler-bank", + host, + account, + params, + normalizedPath: `${host.toLocaleLowerCase()}/${account}`, + fullPath: `${host}/${account}`, + displayName: `${account}@${host}`, + }; + } + + ////////////////////// + // parsing function + /////////////////////// + + export function fromString( + s: string, + opts: { + // do not check path component format + ignoreComponentError?: boolean; + // take unknown target types as valid + allowUnsupported?: boolean; + } = {}, + ) { + if (!s.startsWith(PAYTO_PREFIX)) { + return opKnownFailure(PaytoParseError.WRONG_PREFIX); + } + + const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?"); + + const firstSlashPos = acct.indexOf("/"); + + const targetType = ( + firstSlashPos === -1 ? acct : acct.slice(0, firstSlashPos) + ) as PaytoType; + if (!opts.allowUnsupported && !supported_targets[targetType]) { + const d = opKnownFailureWithBody(PaytoParseError.UNSUPPORTED, { + targetType, + }); + return d; + } + const targetPath = acct.slice(firstSlashPos + 1); + if (firstSlashPos === -1 || !targetPath) { + return opKnownFailureWithBody(PaytoParseError.INCOMPLETE, { targetType }); + } + + 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 (targetType) { + case "iban": { + if (cs.length !== 1 && cs.length !== 2) { + return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { + targetType, + }); + } + const bic = cs.length === 2 ? cs[0] : undefined; + const iban = cs.length === 1 ? cs[0] : cs[1]; + + const ibaRes = parseIban(iban); + + if (!opts.ignoreComponentError && ibaRes.type === "fail") { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 0, + targetType, + error: ibaRes, + }); + } + + return opFixedSuccess<URI>(createIbanPayto(iban as IbanString, bic, params)); + } + case "bitcoin": { + if (cs.length !== 1 && cs.length !== 2) { + return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { + targetType, + }); + } + + const address = cs[0].toLocaleLowerCase(); + const btRes = BitcoinBech32.decode( + address, + BitcoinBech32.Encodings.BECH32, + ); + if (!opts.ignoreComponentError && btRes.type === "fail") { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + targetType, + error: btRes, + }); + } + + const pubRes = cs.length === 1 ? undefined : parseReservePub(cs[1]); + if (!opts.ignoreComponentError && pubRes && pubRes.type === "fail") { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + targetType, + error: pubRes, + }); + } + + return opFixedSuccess<URI>( + createBitcoinPayto( + address as BtAddrString, + pubRes && pubRes.type === "ok" ? pubRes.body : undefined, + params, + ), + ); + } + + case "x-taler-bank": { + if (cs.length < 2) { + return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { + targetType, + }); + } + + const host = parseHostPortPath(cs.slice(0, -1).join("/")); + if (!opts.ignoreComponentError && !host) { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + targetType, + error: host, + }); + } + const account = parseTalerBankAccount(cs[cs.length - 1]); + if (!opts.ignoreComponentError && !account) { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + targetType, + error: account, + }); + } + + return opFixedSuccess<URI>( + createTalerBank( + host ?? (cs[0] as HostPortPath), + account ?? cs[1], + params, + ), + ); + } + case "taler-reserve": { + if (cs.length < 2) { + return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { + targetType, + }); + } + const exchange = parseHostPortPath(cs.slice(0, -1).join("/")); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 0 as const, + targetType, + error: exchange, + }); + } + + const reservePub = cs[cs.length - 1]; + const pubRes = parseReservePub(reservePub); + if (!opts.ignoreComponentError && pubRes.type === "fail") { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 1 as const, + targetType, + error: pubRes, + }); + } + + return opFixedSuccess<URI>( + createTalerReserve( + exchange ?? (cs[0] as HostPortPath), + pubRes.type === "ok" ? pubRes.body : decodeCrock(reservePub), + params, + ), + ); + } + case "taler-reserve-http": { + if (cs.length < 2) { + return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { + targetType, + }); + } + const exchange = parseHostPortPath(cs.slice(0, -1).join("/"), "http"); + if (!opts.ignoreComponentError && !exchange) { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 0, + targetType, + error: exchange, + }); + } + + const reservePub = cs[cs.length - 1]; + const pubRes = parseReservePub(reservePub); + if (!opts.ignoreComponentError && pubRes.type === "fail") { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 1, + targetType, + error: pubRes, + }); + } + return opFixedSuccess<URI>( + createTalerReserveHttp( + exchange ?? (cs[0] as HostPortPath), + pubRes.type === "ok" ? pubRes.body : decodeCrock(reservePub), + params, + ), + ); + } + case "ethereum": { + if (cs.length !== 1) { + return opKnownFailureWithBody(PaytoParseError.COMPONENTS_LENGTH, { + targetType, + }); + } + const address = parseEthereumAddress(cs[0]); + if (!opts.ignoreComponentError && !address) { + return opKnownFailureWithBody(PaytoParseError.INVALID_TARGET_PATH, { + pos: 0, + targetType, + error: address, + }); + } + return opFixedSuccess<URI>( + createEthereum(address ?? (cs[0] as EthAddrString), params), + ); + } + default: { + if (opts.allowUnsupported) { + return opFixedSuccess<URI>( + createUnsupported(targetType, targetPath, params), + ); + } + assertUnreachable(targetType); + } + } + } + + export function codecFullForPaytoString(): Codec<FullPaytoString> { + return { + decode(x: any, c?: Context): FullPaytoString { + if (typeof x !== "string") { + throw new DecodingError( + `expected string at ${renderContext(c)} but got ${typeof x}`, + ); + } + if (!x.startsWith(PAYTO_PREFIX)) { + throw new DecodingError( + `expected start with payto at ${renderContext(c)} but got "${x}"`, + ); + } + return x as FullPaytoString; + }, + }; + } + + export function codecNormalizedForPaytoString(): Codec<NormalizedPaytoString> { + return { + decode(x: any, c?: Context): NormalizedPaytoString { + if (typeof x !== "string") { + throw new DecodingError( + `expected string at ${renderContext(c)} but got ${typeof x}`, + ); + } + if (!x.startsWith(PAYTO_PREFIX)) { + throw new DecodingError( + `expected start with payto at ${renderContext(c)} but got "${x}"`, + ); + } + return x as NormalizedPaytoString; + }, + }; + } +} + +/** + * @deprecated use Paytos namespace + */ export type PaytoUri = | PaytoUriUnknown | PaytoUriIBAN @@ -41,10 +679,10 @@ export type PaytoUri = | PaytoUriEthereum | PaytoUriBitcoin; -// declare const __payto_str: unique symbol; -// export type PaytoString = string & { [__payto_str]: true }; -export type PaytoString = string; - +/** + * @deprecated use codecForNormalizedPAyto or codecForFullPayto + * @returns + */ export function codecForPaytoString(): Codec<PaytoString> { return { decode(x: any, c?: Context): PaytoString { @@ -53,7 +691,7 @@ export function codecForPaytoString(): Codec<PaytoString> { `expected string at ${renderContext(c)} but got ${typeof x}`, ); } - if (!x.startsWith(paytoPfx)) { + if (!x.startsWith(PAYTO_PREFIX)) { throw new DecodingError( `expected start with payto at ${renderContext(c)} but got "${x}"`, ); @@ -62,17 +700,25 @@ export function codecForPaytoString(): Codec<PaytoString> { }, }; } - +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriGeneric { targetType: PaytoType | string; targetPath: string; params: { [name: string]: string }; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriUnknown extends PaytoUriGeneric { isKnown: false; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriIBAN extends PaytoUriGeneric { isKnown: true; targetType: "iban"; @@ -80,6 +726,9 @@ export interface PaytoUriIBAN extends PaytoUriGeneric { bic?: string; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriTaler extends PaytoUriGeneric { isKnown: true; targetType: "taler-reserve"; @@ -87,6 +736,9 @@ export interface PaytoUriTaler extends PaytoUriGeneric { reservePub: string; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriTalerHttp extends PaytoUriGeneric { isKnown: true; targetType: "taler-reserve-http"; @@ -94,6 +746,9 @@ export interface PaytoUriTalerHttp extends PaytoUriGeneric { reservePub: string; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriTalerBank extends PaytoUriGeneric { isKnown: true; targetType: "x-taler-bank"; @@ -101,6 +756,9 @@ export interface PaytoUriTalerBank extends PaytoUriGeneric { account: string; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriBitcoin extends PaytoUriGeneric { isKnown: true; targetType: "bitcoin"; @@ -108,52 +766,63 @@ export interface PaytoUriBitcoin extends PaytoUriGeneric { segwitAddrs: Array<string>; } +/** + * @deprecated use Paytos namespace + */ export interface PaytoUriEthereum extends PaytoUriGeneric { isKnown: true; targetType: "ethereum"; address: string; } -const paytoPfx = "payto://"; - -export type PaytoType = - | "iban" - | "bitcoin" - | "x-taler-bank" - | "taler-reserve" - | "taler-reserve-http" - | "ethereum"; - +/** + * @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, @@ -163,13 +832,20 @@ export function buildPayto( 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: !second ? [] : generateFakeSegwitAddress(second, first), + segwitAddrs, }; return result; } @@ -248,16 +924,16 @@ export function addPaytoQueryParams( s: string, params: { [name: string]: string }, ): string { - const [acct, search] = s.slice(paytoPfx.length).split("?"); + const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?"); const searchParams = new URLSearchParams(search || ""); for (const [paramKey, paramValue] of Object.entries(params)) { searchParams.set(paramKey, paramValue); } const paramList = [...searchParams.entries()]; if (paramList.length === 0) { - return paytoPfx + acct; + return PAYTO_PREFIX + acct; } - return paytoPfx + acct + "?" + createSearchParams(paramList); + return PAYTO_PREFIX + acct + "?" + createSearchParams(paramList); } /** @@ -283,17 +959,21 @@ function createSearchParams(paramList: [string, string][]): string { /** * Serialize a PaytoURI into a valid payto:// string + * @deprecated use paytos namespace * * @param p * @returns */ export function stringifyPaytoUri(p: PaytoUri): PaytoString { - const url = new URL(`${paytoPfx}${p.targetType}/${p.targetPath}`); + const url = new URL(`${PAYTO_PREFIX}${p.targetType}/${p.targetPath}`); const paramList = !p.params ? [] : Object.entries(p.params); url.search = createSearchParams(paramList); return url.href as PaytoString; } +/** + * @deprecated use paytos namespace + */ export function hashFullPaytoUri(p: PaytoUri | string): Uint8Array { const paytoUri = typeof p === "string" ? p : stringifyPaytoUri(p); return hashTruncate32(stringToBytes(paytoUri + "\0")); @@ -301,6 +981,7 @@ export function hashFullPaytoUri(p: PaytoUri | string): Uint8Array { /** * Normalize and then hash a payto URI. + * @deprecated use paytos namespace */ export function hashNormalizedPaytoUri(p: PaytoUri | string): Uint8Array { const paytoUri = typeof p === "string" ? p : stringifyPaytoUri(p); @@ -346,6 +1027,14 @@ export function hashNormalizedPaytoUri(p: PaytoUri | string): Uint8Array { return hashTruncate32(stringToBytes(paytoStr + "\0")); } +/** + * @deprecated do not use this, create a taler-reserve payto and use + * stringify + * + * @param exchangeBaseUrl + * @param reservePub + * @returns + */ export function stringifyReservePaytoUri( exchangeBaseUrl: string, reservePub: string, @@ -375,6 +1064,12 @@ export function stringifyReservePaytoUri( return `payto://${target}/${domainWithOptPort}${optPath}/${reservePub}`; } +/** + * @deprecated use new Payto namespace functions + * + * @param s + * @returns + */ export function parsePaytoUriOrThrow(s: string): PaytoUri { const ret = parsePaytoUri(s); if (!ret) { @@ -386,16 +1081,17 @@ export function parsePaytoUriOrThrow(s: string): PaytoUri { /** * Parse a valid payto:// uri into a PaytoUri object * RFC 8905 + * @deprecated use new Payto namespace functions * * @param s * @returns */ export function parsePaytoUri(s: string): PaytoUri | undefined { - if (!s.startsWith(paytoPfx)) { + if (!s.startsWith(PAYTO_PREFIX)) { return undefined; } - const [acct, search] = s.slice(paytoPfx.length).split("?"); + const [acct, search] = s.slice(PAYTO_PREFIX.length).split("?"); const firstSlashPos = acct.indexOf("/"); @@ -441,9 +1137,12 @@ export function parsePaytoUri(s: string): PaytoUri | undefined { case "bitcoin": { const msg = /\b([A-Z0-9]{52})\b/.exec(params["message"]); const reserve = !msg ? params["subject"] : msg[0]; - const segwitAddrs = !reserve - ? [] - : generateFakeSegwitAddress(reserve, targetPath); + const pubRes = !reserve ? undefined : Paytos.parseReservePub(reserve); + const addr = + !pubRes || pubRes.type === "fail" + ? undefined + : generateFakeSegwitAddress(pubRes.body, targetPath); + const segwitAddrs = !addr || addr.type === "fail" ? [] : addr.body; const result: PaytoUriBitcoin = { isKnown: true, @@ -504,6 +1203,13 @@ export function parsePaytoUri(s: string): PaytoUri | undefined { } } +/** + * @deprecated do not use this, create a payto object and use stringify + * + * @param exchangeBaseUrl + * @param reservePub + * @returns + */ export function talerPaytoFromExchangeReserve( exchangeBaseUrl: string, reservePub: string, @@ -525,22 +1231,3 @@ 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; - accountToken?: AccessToken; -}; - -export const codecForAccountLetter = (): Codec<AccountLetter> => - buildCodecForObject<AccountLetter>() - .property("infoURL", codecForStringURL(true)) - .property("accountURI", codecForPaytoString()) - .property("accountToken", codecOptional(codecForAccessToken())) - .build("AccountLetter"); diff --git a/packages/taler-util/src/segwit_addr.ts b/packages/taler-util/src/segwit_addr.ts @@ -18,27 +18,23 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import bech32 from "./bech32.js"; - -export default { - encode: encode, - decode: decode, -}; +import { BitcoinBech32 } from "./bech32.js"; +import { BtAddrString, OperationResult, opFixedSuccess, opKnownFailure, Paytos } from "./index.js"; function convertbits( - data: any, + data: Array<number>, frombits: number, tobits: number, pad: boolean, -): any[] { - var acc = 0; - var bits = 0; - var ret = []; - var maxv = (1 << tobits) - 1; - for (var p = 0; p < data.length; ++p) { - var value = data[p]; +): Array<number> | null { + let acc = 0; + let bits = 0; + const ret: Array<number> = []; + const maxv = (1 << tobits) - 1; + for (let p = 0; p < data.length; ++p) { + const value = data[p]; if (value < 0 || value >> frombits !== 0) { - return []; //check this, was returning null + return null; } acc = (acc << frombits) | value; bits += frombits; @@ -52,54 +48,68 @@ function convertbits( ret.push((acc << (tobits - bits)) & maxv); } } else if (bits >= frombits || (acc << (tobits - bits)) & maxv) { - return []; //check this, was returning null + return null; } return ret; } -function decode(hrp: any, addr: string) { - var bech32m = false; - var dec = bech32.decode(addr, bech32.encodings.BECH32); - if (dec === null) { - dec = bech32.decode(addr, bech32.encodings.BECH32M); - bech32m = true; - } - if ( - dec === null || - dec.hrp !== hrp || - dec.data.length < 1 || - dec.data[0] > 16 - ) { - return null; - } - var res = convertbits(dec.data.slice(1), 5, 8, false); - if (res === null || res.length < 2 || res.length > 40) { - return null; - } - if (dec.data[0] === 0 && res.length !== 20 && res.length !== 32) { - return null; - } - if (dec.data[0] === 0 && bech32m) { - return null; - } - if (dec.data[0] !== 0 && !bech32m) { - return null; +export namespace BitcoinSewgit { + export enum BitcoinSewgitParseError { + /** + * Input data is wrong + */ + INVALID_DATA = "invalid-data", + /** + * Generic parsing problem + */ + DECODING_PROBLEM = "decoding-problem", } - return { version: dec.data[0], program: res }; -} + export function decode( + addr: string, + enc?: BitcoinBech32.Encodings, + ): OperationResult< + { version: number; program: Array<number> }, + BitcoinBech32.BitcoinParseError | BitcoinSewgitParseError + > { + const decResp = BitcoinBech32.decode(addr, enc); + if (decResp.type === "fail") { + return decResp; + } + const { body: dec } = decResp; -function encode(hrp: any, version: number, program: any): string { - var enc = bech32.encodings.BECH32; - if (version > 0) { - enc = bech32.encodings.BECH32M; + if (dec.data.length < 1 || dec.data[0] > 16) { + return opKnownFailure(BitcoinSewgitParseError.INVALID_DATA); + } + const res = convertbits(dec.data.slice(1), 5, 8, false); + if (res === null || res.length < 2 || res.length > 40) { + return opKnownFailure(BitcoinSewgitParseError.DECODING_PROBLEM); + } + if (dec.data[0] === 0 && res.length !== 20 && res.length !== 32) { + return opKnownFailure(BitcoinSewgitParseError.DECODING_PROBLEM); + } + if (dec.data[0] === 0 && enc === BitcoinBech32.Encodings.BECH32) { + return opKnownFailure(BitcoinSewgitParseError.DECODING_PROBLEM); + } + if (dec.data[0] !== 0 && enc === BitcoinBech32.Encodings.BECH32M) { + return opKnownFailure(BitcoinSewgitParseError.DECODING_PROBLEM); + } + return opFixedSuccess({ version: dec.data[0], program: res }); } - var ret = bech32.encode( - hrp, - [version].concat(convertbits(program, 8, 5, true)), - enc, - ); - if (decode(hrp, ret /*, enc*/) === null) { - return ""; //check this was returning null + + export function encode( + hrp: string, + version: number, + program: Array<number>, + ): OperationResult<BtAddrString, BitcoinSewgitParseError> { + const enc = + version > 0 + ? BitcoinBech32.Encodings.BECH32M + : BitcoinBech32.Encodings.BECH32; + const bits = convertbits(program, 8, 5, true); + if (!bits) { + return opKnownFailure(BitcoinSewgitParseError.INVALID_DATA); + } + const ret = BitcoinBech32.encode(hrp, [version].concat(bits), enc); + return opFixedSuccess(ret); } - return ret; } diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1931,7 +1931,7 @@ export interface MerchantAccountKycRedirect { status: MerchantAccountKycStatus; // Our bank wire account this is about. - payto_uri: string; + payto_uri: PaytoString; // Hash of the salted payto://-URI of our // bank wire account this is about. diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/index.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson, ScopeInfo } from "@gnu-taler/taler-util"; +import { AmountJson, PaytoString, ScopeInfo } from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; @@ -63,7 +63,7 @@ export namespace State { status: "manage-account"; error: undefined; scope: ScopeInfo; - onAccountSelected: (paytoUri: string) => void; + onAccountSelected: (paytoUri: PaytoString) => void; onCancel: () => void; } diff --git a/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts b/packages/taler-wallet-webextension/src/wallet/DepositPage/state.ts @@ -16,6 +16,7 @@ import { Amounts, + PaytoString, TransactionAmountMode, WalletBankAccountInfo, } from "@gnu-taler/taler-util"; @@ -53,7 +54,7 @@ export function useComponentState({ return { accounts, balances }; }); - const [selectedAccount, setSelectedAccount] = useState<string>(); + const [selectedAccount, setSelectedAccount] = useState<PaytoString>(); const [addingAccount, setAddingAccount] = useState(false); @@ -75,7 +76,7 @@ export function useComponentState({ } const { accounts, balances } = hook.response; - async function updateAccountFromList(accountStr: string): Promise<void> { + async function updateAccountFromList(accountStr: PaytoString): Promise<void> { const uri = accountStr ?? undefined; if (uri) { setSelectedAccount(uri); @@ -87,7 +88,7 @@ export function useComponentState({ status: "manage-account", error: undefined, scope, - onAccountSelected: (p: string) => { + onAccountSelected: (p: PaytoString) => { updateAccountFromList(p); setAddingAccount(false); hook.retry(); @@ -125,7 +126,7 @@ export function useComponentState({ }, }; } - const firstAccount = accounts[0].paytoUri; + const firstAccount = accounts[0].paytoUri as PaytoString; const currentAccount = !selectedAccount ? firstAccount : selectedAccount; return (): State => { const [instructed, setInstructed] = useState({ diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts b/packages/taler-wallet-webextension/src/wallet/ManageAccount/index.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { WalletBankAccountInfo, ScopeInfo } from "@gnu-taler/taler-util"; +import { WalletBankAccountInfo, ScopeInfo, PaytoString } from "@gnu-taler/taler-util"; import { ErrorAlertView } from "../../components/CurrentAlerts.js"; import { Loading } from "../../components/Loading.js"; import { ErrorAlert } from "../../context/alert.js"; @@ -29,7 +29,7 @@ import { ReadyView } from "./views.js"; export interface Props { scope: ScopeInfo; - onAccountSelected: (uri: string) => void; + onAccountSelected: (uri: PaytoString) => void; onCancel: () => void; } diff --git a/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts b/packages/taler-wallet-webextension/src/wallet/ManageAccount/state.ts @@ -16,6 +16,7 @@ import { parsePaytoUri, + PaytoString, stringifyPaytoUri, WalletBankAccountInfo, } from "@gnu-taler/taler-util"; @@ -158,7 +159,7 @@ export function useComponentState({ }), }, selectAccount: async (w) => { - onAccountSelected(w.paytoUri); + onAccountSelected(w.paytoUri as PaytoString); // trust in the wallet that the payto is correct }, alias: { value: label,