/* This file is part of GNU Taler (C) 2019 GNUnet e.V. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ import { generateFakeSegwitAddress } from "./bitcoin.js"; import { Codec, Context, DecodingError, buildCodecForObject, codecForStringURL, renderContext, } from "./codec.js"; import { AccessToken, bytesToString, codecForAccessToken, codecOptional, hashTruncate32, stringToBytes, } from "./index.js"; import { URLSearchParams } from "./url.js"; export type PaytoUri = | PaytoUriUnknown | PaytoUriIBAN | PaytoUriTalerBank | PaytoUriBitcoin; declare const __payto_str: unique symbol; export type PaytoString = string & { [__payto_str]: true }; export function codecForPaytoString(): Codec { return { decode(x: any, c?: Context): PaytoString { if (typeof x !== "string") { throw new DecodingError( `expected string at ${renderContext(c)} but got ${typeof x}`, ); } if (!x.startsWith(paytoPfx)) { throw new DecodingError( `expected start with payto at ${renderContext(c)} but got "${x}"`, ); } return x as PaytoString; }, }; } export interface PaytoUriGeneric { targetType: PaytoType | string; targetPath: string; params: { [name: string]: string }; } export interface PaytoUriUnknown extends PaytoUriGeneric { isKnown: false; } export interface PaytoUriIBAN extends PaytoUriGeneric { isKnown: true; targetType: "iban"; iban: string; bic?: string; } export interface PaytoUriTalerBank extends PaytoUriGeneric { isKnown: true; targetType: "x-taler-bank"; host: string; account: string; } export interface PaytoUriBitcoin extends PaytoUriGeneric { isKnown: true; targetType: "bitcoin"; address: string; segwitAddrs: Array; } const paytoPfx = "payto://"; export type PaytoType = "iban" | "bitcoin" | "x-taler-bank"; export function buildPayto( type: "iban", iban: string, bic: string | undefined, ): PaytoUriIBAN; export function buildPayto( type: "bitcoin", address: string, reserve: string | undefined, ): PaytoUriBitcoin; export function buildPayto( type: "x-taler-bank", host: string, account: string, ): PaytoUriTalerBank; export function buildPayto( type: PaytoType, first: string, second?: string, ): PaytoUriGeneric { switch (type) { case "bitcoin": { const uppercased = first.toUpperCase(); const result: PaytoUriBitcoin = { isKnown: true, targetType: "bitcoin", targetPath: first, address: uppercased, params: {}, segwitAddrs: !second ? [] : generateFakeSegwitAddress(second, first), }; return result; } case "iban": { const uppercased = first.toUpperCase(); const result: PaytoUriIBAN = { isKnown: true, targetType: "iban", iban: uppercased, params: {}, targetPath: !second ? uppercased : `${second}/${uppercased}`, }; return result; } case "x-taler-bank": { if (!second) throw Error("missing account for payto://x-taler-bank"); const result: PaytoUriTalerBank = { isKnown: true, targetType: "x-taler-bank", host: first, account: second, params: {}, targetPath: `${first}/${second}`, }; return result; } default: { const unknownType: never = type; throw Error(`unknown payto:// type ${unknownType}`); } } } /** * Add query parameters to a payto URI */ export function addPaytoQueryParams( s: string, params: { [name: string]: string }, ): string { const [acct, search] = s.slice(paytoPfx.length).split("?"); const paramList = !params ? [] : Object.entries(params); if (paramList.length === 0) { return paytoPfx + acct; } return paytoPfx + acct + "?" + createSearchParams(paramList); } /** * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986 */ function encodeRFC3986URIComponent(str: string): string { return encodeURIComponent(str).replace( /[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, ); } const rfc3986 = encodeRFC3986URIComponent; /** * * https://www.rfc-editor.org/rfc/rfc3986 */ function createSearchParams(paramList: [string, string][]): string { return paramList .map(([key, value]) => `${rfc3986(key)}=${rfc3986(value)}`) .join("&"); } /** * Serialize a PaytoURI into a valid payto:// string * * @param p * @returns */ export function stringifyPaytoUri(p: PaytoUri): PaytoString { const url = new URL(`${paytoPfx}${p.targetType}/${p.targetPath}`); const paramList = !p.params ? [] : Object.entries(p.params); url.search = createSearchParams(paramList); return url.href as PaytoString; } export function hashPaytoUri(p: PaytoUri): string { const paytoUri = stringifyPaytoUri(p); return bytesToString(hashTruncate32(stringToBytes(paytoUri + "\0"))); } /** * Parse a valid payto:// uri into a PaytoUri object * RFC 8905 * * @param s * @returns */ export function parsePaytoUri(s: string): PaytoUri | undefined { if (!s.startsWith(paytoPfx)) { return undefined; } const [acct, search] = s.slice(paytoPfx.length).split("?"); const firstSlashPos = acct.indexOf("/"); if (firstSlashPos === -1) { return undefined; } const targetType = acct.slice(0, firstSlashPos); const targetPath = acct.slice(firstSlashPos + 1); const params: { [k: string]: string } = {}; const searchParams = new URLSearchParams(search || ""); searchParams.forEach((v, k) => { // URLSearchParams already decodes uri components params[k] = v; //decodeURIComponent(v); }); if (targetType === "x-taler-bank") { const parts = targetPath.split("/"); const host = parts[0]; const account = parts[1]; return { targetPath, targetType, params, isKnown: true, host, account, }; } if (targetType === "iban") { const parts = targetPath.split("/"); let iban: string | undefined = undefined; let bic: string | undefined = undefined; if (parts.length === 1) { iban = parts[0].toUpperCase(); } if (parts.length === 2) { bic = parts[0]; iban = parts[1].toUpperCase(); } else { iban = targetPath.toUpperCase(); } return { isKnown: true, targetPath, targetType, params, iban, bic, }; } if (targetType === "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 uppercased = targetType.toUpperCase(); const result: PaytoUriBitcoin = { isKnown: true, targetPath, targetType, address: uppercased, params, segwitAddrs, }; return result; } return { targetPath, targetType, params, isKnown: false, }; } export function talerPaytoFromExchangeReserve( exchangeBaseUrl: string, reservePub: string, ): string { const url = new URL(exchangeBaseUrl); let proto: string; if (url.protocol === "http:") { proto = "taler-reserve-http"; } else if (url.protocol === "https:") { proto = "taler-reserve"; } else { throw Error(`unsupported exchange base URL protocol (${url.protocol})`); } let path = url.pathname; if (!path.endsWith("/")) { path = path + "/"; } 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 => buildCodecForObject() .property("infoURL", codecForStringURL(true)) .property("accountURI", codecForPaytoString()) .property("accountToken", codecOptional(codecForAccessToken())) .build("AccountLetter");