diff --git a/dependency-graph.json b/dependency-graph.json index b9d4b73a5cd..1b7659699b4 100644 --- a/dependency-graph.json +++ b/dependency-graph.json @@ -215,6 +215,15 @@ "@celo/flake-tracker" ] }, + "@celo/phone-utils": { + "location": "packages/sdk/phone-utils", + "dependencies": [ + "@celo/base", + "@celo/flake-tracker", + "@celo/typescript", + "@celo/utils" + ] + }, "@celo/transactions-uri": { "location": "packages/sdk/transactions-uri", "dependencies": [ diff --git a/packages/sdk/phone-utils/.gitignore b/packages/sdk/phone-utils/.gitignore new file mode 100644 index 00000000000..592a8d4a357 --- /dev/null +++ b/packages/sdk/phone-utils/.gitignore @@ -0,0 +1,3 @@ +*.js +!jest.config.js +lib \ No newline at end of file diff --git a/packages/sdk/phone-utils/.npmignore b/packages/sdk/phone-utils/.npmignore new file mode 100644 index 00000000000..c206e25b7b1 --- /dev/null +++ b/packages/sdk/phone-utils/.npmignore @@ -0,0 +1,7 @@ +/node_modules +/coverage +/tslint.json +/tsconfig.json +/test +/src +**.test.js \ No newline at end of file diff --git a/packages/sdk/phone-utils/jest.config.js b/packages/sdk/phone-utils/jest.config.js new file mode 100644 index 00000000000..86abcf4a671 --- /dev/null +++ b/packages/sdk/phone-utils/jest.config.js @@ -0,0 +1,7 @@ +const { nodeFlakeTracking } = require('@celo/flake-tracker/src/jest/config.js') + +module.exports = { + preset: 'ts-jest', + testMatch: ['/src/**/?(*.)+(spec|test).ts?(x)'], + ...nodeFlakeTracking, +} diff --git a/packages/sdk/phone-utils/package.json b/packages/sdk/phone-utils/package.json new file mode 100644 index 00000000000..96f11b716af --- /dev/null +++ b/packages/sdk/phone-utils/package.json @@ -0,0 +1,38 @@ +{ + "name": "@celo/phone-utils", + "version": "1.3.3-dev", + "description": "Celo phone utils", + "author": "Celo", + "license": "Apache-2.0", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "sideEffects": false, + "scripts": { + "prepublishOnly": "yarn build", + "build": "tsc -b .", + "clean": "tsc -b . --clean", + "docs": "typedoc && ts-node scripts/linkdocs.ts utils", + "test": "jest --runInBand --ci", + "test:verbose": "jest --verbose", + "lint": "tslint -c tslint.json --project ." + }, + "files": [ + "lib/**/*" + ], + "dependencies": { + "@celo/base": "1.3.3-dev", + "@celo/utils": "1.3.3-dev", + "@types/country-data": "^0.0.0", + "@types/ethereumjs-util": "^5.2.0", + "@types/google-libphonenumber": "^7.4.17", + "@types/node": "^10.12.18", + "country-data": "^0.0.31", + "fp-ts": "2.1.1", + "io-ts": "2.0.1", + "google-libphonenumber": "^3.2.15" + }, + "devDependencies": { + "@celo/flake-tracker": "0.0.1-dev", + "@celo/typescript": "0.0.1" + } +} \ No newline at end of file diff --git a/packages/sdk/phone-utils/src/countries.test.ts b/packages/sdk/phone-utils/src/countries.test.ts new file mode 100644 index 00000000000..4ccecf71660 --- /dev/null +++ b/packages/sdk/phone-utils/src/countries.test.ts @@ -0,0 +1,174 @@ +import { Countries } from './countries' + +const countries = new Countries('en-us') + +describe('countries', () => { + describe('getCountryMap', () => { + test('Valid Country', () => { + const country = countries.getCountryByCodeAlpha2('US') + + expect(country).toBeDefined() + + // check these to make tsc happy + if (country && country.names) { + expect(country.names['en-us']).toEqual('United States') + } + }) + + test('Invalid Country', () => { + // canary islands, no calling code + const invalidCountry = countries.getCountryByCodeAlpha2('IC') + + expect(invalidCountry).toBeUndefined() + }) + }) + describe('getCountry', () => { + test('has all country data', () => { + const country = countries.getCountry('taiwan') + + expect(country).toMatchInlineSnapshot(` + Object { + "alpha2": "TW", + "alpha3": "TWN", + "countryCallingCode": "+886", + "countryCallingCodes": Array [ + "+886", + ], + "countryPhonePlaceholder": Object { + "national": "00 0000 0000", + }, + "currencies": Array [ + "TWD", + ], + "displayName": "Taiwan", + "displayNameNoDiacritics": "taiwan", + "emoji": "🇹🇼", + "ioc": "TPE", + "languages": Array [ + "zho", + ], + "name": "Taiwan", + "names": Object { + "en-us": "Taiwan", + "es-419": "Taiwán", + }, + "status": "assigned", + } + `) + }) + }) + + describe('Country Search', () => { + test('finds an exact match', () => { + const results = countries.getFilteredCountries('taiwan') + + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "alpha2": "TW", + "alpha3": "TWN", + "countryCallingCode": "+886", + "countryCallingCodes": Array [ + "+886", + ], + "countryPhonePlaceholder": Object { + "national": "00 0000 0000", + }, + "currencies": Array [ + "TWD", + ], + "displayName": "Taiwan", + "displayNameNoDiacritics": "taiwan", + "emoji": "🇹🇼", + "ioc": "TPE", + "languages": Array [ + "zho", + ], + "name": "Taiwan", + "names": Object { + "en-us": "Taiwan", + "es-419": "Taiwán", + }, + "status": "assigned", + }, + ] + `) + }) + + test('finds countries by calling code', () => { + const results = countries.getFilteredCountries('49') + + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "alpha2": "DE", + "alpha3": "DEU", + "countryCallingCode": "+49", + "countryCallingCodes": Array [ + "+49", + ], + "countryPhonePlaceholder": Object { + "national": "000 000000", + }, + "currencies": Array [ + "EUR", + ], + "displayName": "Germany", + "displayNameNoDiacritics": "germany", + "emoji": "🇩🇪", + "ioc": "GER", + "languages": Array [ + "deu", + ], + "name": "Germany", + "names": Object { + "en-us": "Germany", + "es-419": "Alemania", + }, + "status": "assigned", + }, + ] + `) + }) + + test('finds countries by ISO code', () => { + const results = countries.getFilteredCountries('gb') + + expect(results).toMatchInlineSnapshot(` + Array [ + Object { + "alpha2": "GB", + "alpha3": "GBR", + "countryCallingCode": "+44", + "countryCallingCodes": Array [ + "+44", + ], + "countryPhonePlaceholder": Object { + "national": "0000 000 0000", + }, + "currencies": Array [ + "GBP", + ], + "displayName": "United Kingdom", + "displayNameNoDiacritics": "united kingdom", + "emoji": "🇬🇧", + "ioc": "GBR", + "languages": Array [ + "eng", + "cor", + "gle", + "gla", + "cym", + ], + "name": "United Kingdom", + "names": Object { + "en-us": "United Kingdom", + "es-419": "Reino Unido", + }, + "status": "assigned", + }, + ] + `) + }) + }) +}) diff --git a/packages/sdk/phone-utils/src/countries.ts b/packages/sdk/phone-utils/src/countries.ts new file mode 100644 index 00000000000..e5ffe3319cc --- /dev/null +++ b/packages/sdk/phone-utils/src/countries.ts @@ -0,0 +1,116 @@ +import countryData from 'country-data' +// more countries @ https://github.com/umpirsky/country-list +import esData from './data/countries/es/country.json' +import { getExampleNumber } from './phoneNumbers' + +interface CountryNames { + [name: string]: string +} + +export interface LocalizedCountry extends Omit { + displayName: string + displayNameNoDiacritics: string + names: CountryNames + countryPhonePlaceholder: { + national?: string | undefined + international?: string | undefined + } + countryCallingCode: string +} + +const removeDiacritics = (word: string) => + word && + word + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .trim() + +const matchCountry = (country: LocalizedCountry, query: string) => { + return ( + country.displayNameNoDiacritics.startsWith(query) || + country.countryCallingCode.startsWith('+' + query) || + country.alpha3.startsWith(query.toUpperCase()) + ) +} + +export class Countries { + language: string + countryMap: Map + localizedCountries: LocalizedCountry[] + + constructor(language?: string) { + // fallback to 'en-us' + this.language = language ? language.toLocaleLowerCase() : 'en-us' + this.countryMap = new Map() + this.localizedCountries = Array() + this.assignCountries() + } + + getCountry(countryName?: string | null): LocalizedCountry | undefined { + if (!countryName) { + return undefined + } + + const query = removeDiacritics(countryName) + + return this.localizedCountries.find((country) => country.displayNameNoDiacritics === query) + } + + getCountryByCodeAlpha2(countryCode: string): LocalizedCountry | undefined { + return this.countryMap.get(countryCode) + } + + getFilteredCountries(query: string): LocalizedCountry[] { + query = removeDiacritics(query) + // Return full list if the query is empty + if (!query || !query.length) { + return this.localizedCountries + } + + return this.localizedCountries.filter((country) => matchCountry(country, query)) + } + + private assignCountries() { + // add other languages to country data + this.localizedCountries = countryData.callingCountries.all + .map((country: countryData.Country) => { + // this is assuming these two are the only cases, in i18n.ts seems like there + // are fallback languages 'es-US' and 'es-LA' that are not covered + const names: CountryNames = { + 'en-us': country.name, + // @ts-ignore + 'es-419': esData[country.alpha2], + } + + const displayName = names[this.language] || country.name + + // We only use the first calling code, others are irrelevant in the current dataset. + // Also some of them have a non standard calling code + // for instance: 'Antigua And Barbuda' has '+1 268', where only '+1' is expected + // so we fix this here + const countryCallingCode = country.countryCallingCodes[0].split(' ')[0] + + const localizedCountry = { + names, + displayName, + displayNameNoDiacritics: removeDiacritics(displayName), + countryPhonePlaceholder: { + national: getExampleNumber(countryCallingCode), + // Not needed right now + // international: getExampleNumber(countryCallingCode, true, true), + }, + countryCallingCode, + ...country, + // Use default emoji when flag emoji is missing + emoji: country.emoji || '🏳', + } + + // use ISO 3166-1 alpha2 code as country id + this.countryMap.set(country.alpha2.toUpperCase(), localizedCountry) + + return localizedCountry + }) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + } +} diff --git a/packages/sdk/phone-utils/src/data/countries/es/country.json b/packages/sdk/phone-utils/src/data/countries/es/country.json new file mode 100644 index 00000000000..ee1e3cecc0c --- /dev/null +++ b/packages/sdk/phone-utils/src/data/countries/es/country.json @@ -0,0 +1 @@ +{"AF":"Afganist\u00e1n","AL":"Albania","DE":"Alemania","AD":"Andorra","AO":"Angola","AI":"Anguila","AQ":"Ant\u00e1rtida","AG":"Antigua y Barbuda","SA":"Arabia Saud\u00ed","DZ":"Argelia","AR":"Argentina","AM":"Armenia","AW":"Aruba","AU":"Australia","AT":"Austria","AZ":"Azerbaiy\u00e1n","BS":"Bahamas","BD":"Banglad\u00e9s","BB":"Barbados","BH":"Bar\u00e9in","BE":"B\u00e9lgica","BZ":"Belice","BJ":"Ben\u00edn","BM":"Bermudas","BY":"Bielorrusia","BO":"Bolivia","BA":"Bosnia y Herzegovina","BW":"Botsuana","BR":"Brasil","BN":"Brun\u00e9i","BG":"Bulgaria","BF":"Burkina Faso","BI":"Burundi","BT":"But\u00e1n","CV":"Cabo Verde","KH":"Camboya","CM":"Camer\u00fan","CA":"Canad\u00e1","IC":"Canarias","BQ":"Caribe neerland\u00e9s","QA":"Catar","EA":"Ceuta y Melilla","TD":"Chad","CZ":"Chequia","CL":"Chile","CN":"China","CY":"Chipre","VA":"Ciudad del Vaticano","CO":"Colombia","KM":"Comoras","CG":"Congo","KP":"Corea del Norte","KR":"Corea del Sur","CR":"Costa Rica","CI":"C\u00f4te d\u2019Ivoire","HR":"Croacia","CU":"Cuba","CW":"Curazao","DG":"Diego Garc\u00eda","DK":"Dinamarca","DM":"Dominica","EC":"Ecuador","EG":"Egipto","SV":"El Salvador","AE":"Emiratos \u00c1rabes Unidos","ER":"Eritrea","SK":"Eslovaquia","SI":"Eslovenia","ES":"Espa\u00f1a","US":"Estados Unidos","EE":"Estonia","SZ":"Esuatini","ET":"Etiop\u00eda","PH":"Filipinas","FI":"Finlandia","FJ":"Fiyi","FR":"Francia","GA":"Gab\u00f3n","GM":"Gambia","GE":"Georgia","GH":"Ghana","GI":"Gibraltar","GD":"Granada","GR":"Grecia","GL":"Groenlandia","GP":"Guadalupe","GU":"Guam","GT":"Guatemala","GF":"Guayana Francesa","GG":"Guernsey","GN":"Guinea","GQ":"Guinea Ecuatorial","GW":"Guinea-Bis\u00e1u","GY":"Guyana","HT":"Hait\u00ed","HN":"Honduras","HU":"Hungr\u00eda","IN":"India","ID":"Indonesia","IQ":"Irak","IR":"Ir\u00e1n","IE":"Irlanda","AC":"Isla de la Ascensi\u00f3n","IM":"Isla de Man","CX":"Isla de Navidad","NF":"Isla Norfolk","IS":"Islandia","AX":"Islas \u00c5land","KY":"Islas Caim\u00e1n","CC":"Islas Cocos","CK":"Islas Cook","FO":"Islas Feroe","GS":"Islas Georgia del Sur y Sandwich del Sur","FK":"Islas Malvinas","MP":"Islas Marianas del Norte","MH":"Islas Marshall","UM":"Islas menores alejadas de EE. UU.","PN":"Islas Pitcairn","SB":"Islas Salom\u00f3n","TC":"Islas Turcas y Caicos","VG":"Islas V\u00edrgenes Brit\u00e1nicas","VI":"Islas V\u00edrgenes de EE. UU.","IL":"Israel","IT":"Italia","JM":"Jamaica","JP":"Jap\u00f3n","JE":"Jersey","JO":"Jordania","KZ":"Kazajist\u00e1n","KE":"Kenia","KG":"Kirguist\u00e1n","KI":"Kiribati","XK":"Kosovo","KW":"Kuwait","LA":"Laos","LS":"Lesoto","LV":"Letonia","LB":"L\u00edbano","LR":"Liberia","LY":"Libia","LI":"Liechtenstein","LT":"Lituania","LU":"Luxemburgo","MK":"Macedonia","MG":"Madagascar","MY":"Malasia","MW":"Malaui","MV":"Maldivas","ML":"Mali","MT":"Malta","MA":"Marruecos","MQ":"Martinica","MU":"Mauricio","MR":"Mauritania","YT":"Mayotte","MX":"M\u00e9xico","FM":"Micronesia","MD":"Moldavia","MC":"M\u00f3naco","MN":"Mongolia","ME":"Montenegro","MS":"Montserrat","MZ":"Mozambique","MM":"Myanmar (Birmania)","NA":"Namibia","NR":"Nauru","NP":"Nepal","NI":"Nicaragua","NE":"N\u00edger","NG":"Nigeria","NU":"Niue","NO":"Noruega","NC":"Nueva Caledonia","NZ":"Nueva Zelanda","OM":"Om\u00e1n","NL":"Pa\u00edses Bajos","PK":"Pakist\u00e1n","PW":"Palaos","PA":"Panam\u00e1","PG":"Pap\u00faa Nueva Guinea","PY":"Paraguay","PE":"Per\u00fa","PF":"Polinesia Francesa","PL":"Polonia","PT":"Portugal","XA":"Pseudo-Accents","XB":"Pseudo-Bidi","PR":"Puerto Rico","HK":"RAE de Hong Kong (China)","MO":"RAE de Macao (China)","GB":"Reino Unido","CF":"Rep\u00fablica Centroafricana","CD":"Rep\u00fablica Democr\u00e1tica del Congo","DO":"Rep\u00fablica Dominicana","RE":"Reuni\u00f3n","RW":"Ruanda","RO":"Ruman\u00eda","RU":"Rusia","EH":"S\u00e1hara Occidental","WS":"Samoa","AS":"Samoa Americana","BL":"San Bartolom\u00e9","KN":"San Crist\u00f3bal y Nieves","SM":"San Marino","MF":"San Mart\u00edn","PM":"San Pedro y Miquel\u00f3n","VC":"San Vicente y las Granadinas","SH":"Santa Elena","LC":"Santa Luc\u00eda","ST":"Santo Tom\u00e9 y Pr\u00edncipe","SN":"Senegal","RS":"Serbia","SC":"Seychelles","SL":"Sierra Leona","SG":"Singapur","SX":"Sint Maarten","SY":"Siria","SO":"Somalia","LK":"Sri Lanka","ZA":"Sud\u00e1frica","SD":"Sud\u00e1n","SS":"Sud\u00e1n del Sur","SE":"Suecia","CH":"Suiza","SR":"Surinam","SJ":"Svalbard y Jan Mayen","TH":"Tailandia","TW":"Taiw\u00e1n","TZ":"Tanzania","TJ":"Tayikist\u00e1n","IO":"Territorio Brit\u00e1nico del Oc\u00e9ano \u00cdndico","TF":"Territorios Australes Franceses","PS":"Territorios Palestinos","TL":"Timor-Leste","TG":"Togo","TK":"Tokelau","TO":"Tonga","TT":"Trinidad y Tobago","TA":"Trist\u00e1n de Acu\u00f1a","TN":"T\u00fanez","TM":"Turkmenist\u00e1n","TR":"Turqu\u00eda","TV":"Tuvalu","UA":"Ucrania","UG":"Uganda","UY":"Uruguay","UZ":"Uzbekist\u00e1n","VU":"Vanuatu","VE":"Venezuela","VN":"Vietnam","WF":"Wallis y Futuna","YE":"Yemen","DJ":"Yibuti","ZM":"Zambia","ZW":"Zimbabue"} \ No newline at end of file diff --git a/packages/sdk/phone-utils/src/getCountryEmoji.ts b/packages/sdk/phone-utils/src/getCountryEmoji.ts new file mode 100644 index 00000000000..9735b3f06e2 --- /dev/null +++ b/packages/sdk/phone-utils/src/getCountryEmoji.ts @@ -0,0 +1,23 @@ +import CountryData from 'country-data' +import { getCountryCode, getRegionCode } from './phoneNumbers' + +export function getCountryEmoji( + e164PhoneNumber: string, + countryCodePossible?: number, + regionCodePossible?: string +) { + // The country code and region code can both be passed in, or it can be inferred from the e164PhoneNumber + let countryCode: any + let regionCode: any + countryCode = countryCodePossible + regionCode = regionCodePossible + if (!countryCode || !regionCode) { + countryCode = getCountryCode(e164PhoneNumber) + regionCode = getRegionCode(e164PhoneNumber) + } + const countries = CountryData.lookup.countries({ countryCallingCodes: `+${countryCode}` }) + const userCountryArray = countries.filter((c: any) => c.alpha2 === regionCode) + const country = userCountryArray.length > 0 ? userCountryArray[0] : undefined + + return country ? country.emoji : '' +} diff --git a/packages/sdk/phone-utils/src/getPhoneHash.ts b/packages/sdk/phone-utils/src/getPhoneHash.ts new file mode 100644 index 00000000000..aef6b90643f --- /dev/null +++ b/packages/sdk/phone-utils/src/getPhoneHash.ts @@ -0,0 +1,9 @@ +import { getPhoneHash as baseGetPhoneHash } from '@celo/base/lib/phoneNumbers' +import { soliditySha3 } from 'web3-utils' + +const sha3 = (v: string): string | null => soliditySha3({ type: 'string', value: v }) +const getPhoneHash = (phoneNumber: string, salt?: string): string => { + return baseGetPhoneHash(sha3, phoneNumber, salt) +} + +export default getPhoneHash diff --git a/packages/sdk/phone-utils/src/index.ts b/packages/sdk/phone-utils/src/index.ts new file mode 100644 index 00000000000..635aef556cb --- /dev/null +++ b/packages/sdk/phone-utils/src/index.ts @@ -0,0 +1,19 @@ +export * from './countries' +export { Countries, LocalizedCountry } from './countries' +export * from './getCountryEmoji' +export * from './getPhoneHash' +export * from './inputValidation' +export * from './io' +export { + getCountryCode, + getDisplayNumberInternational, + getDisplayPhoneNumber, + getE164DisplayNumber, + getE164Number, + getExampleNumber, + getRegionCode, + getRegionCodeFromCountryCode, + isE164NumberStrict, + parsePhoneNumber, + PhoneNumberUtils, +} from './phoneNumbers' diff --git a/packages/sdk/phone-utils/src/inputValidation.test.ts b/packages/sdk/phone-utils/src/inputValidation.test.ts new file mode 100644 index 00000000000..5d5e9d0f3d2 --- /dev/null +++ b/packages/sdk/phone-utils/src/inputValidation.test.ts @@ -0,0 +1,48 @@ +import { BaseProps, ValidatorKind } from '@celo/base/lib/inputValidation' +import { validateInput } from './inputValidation' + +describe('inputValidation', () => { + function validateFunction( + itStr: string, + inputs: string[], + validator: ValidatorKind, + expected: string, + props?: BaseProps + ) { + it(itStr, () => + inputs.forEach((input) => { + const result = validateInput(input, { validator, countryCallingCode: '1', ...props }) + expect(result).toEqual(expected) + }) + ) + } + + const numbers = ['bu1.23n', '1.2.3', '1.23', '1.2.-_[`/,zx3.....', '1.b.23'] + + validateFunction('validates integers', numbers, ValidatorKind.Integer, '123') + + validateFunction('validates decimals', numbers, ValidatorKind.Decimal, '1.23') + + validateFunction( + 'allows comma decimals', + numbers.map((val) => val.replace('.', ',')), + ValidatorKind.Decimal, + '1,23', + { decimalSeparator: ',' } + ) + + validateFunction( + 'validates phone numbers', + [ + '4023939889', + '(402)3939889', + '(402)393-9889', + '402bun393._=988-9', + '402 393 9889', + '(4023) 9-39-88-9', + '4-0-2-3-9-3-9-8-8-9', // phone-kebab + ], + ValidatorKind.Phone, + '(402) 393-9889' + ) +}) diff --git a/packages/sdk/phone-utils/src/inputValidation.ts b/packages/sdk/phone-utils/src/inputValidation.ts new file mode 100644 index 00000000000..8979fdd8820 --- /dev/null +++ b/packages/sdk/phone-utils/src/inputValidation.ts @@ -0,0 +1,40 @@ +import { BaseProps, validateDecimal, validateInteger } from '@celo/base/lib/inputValidation' +import { getDisplayPhoneNumber } from './phoneNumbers' + +export function validatePhone(input: string, countryCallingCode?: string): string { + input = input.replace(/[^0-9()\- ]/g, '') + + if (!countryCallingCode) { + return input + } + + const displayNumber = getDisplayPhoneNumber(input, countryCallingCode) + + if (!displayNumber) { + return input + } + + return displayNumber +} + +export function validateInput(input: string, props: BaseProps): string { + if (!props.validator && !props.customValidator) { + return input + } + + switch (props.validator) { + case 'decimal': + return validateDecimal(input, props.decimalSeparator) + case 'integer': + return validateInteger(input) + case 'phone': + return validatePhone(input, props.countryCallingCode) + case 'custom': { + if (props.customValidator) { + return props.customValidator(input) + } + } + } + + throw new Error('Unhandled input validator') +} diff --git a/packages/sdk/phone-utils/src/io.ts b/packages/sdk/phone-utils/src/io.ts new file mode 100644 index 00000000000..5ba2c596402 --- /dev/null +++ b/packages/sdk/phone-utils/src/io.ts @@ -0,0 +1,102 @@ +import { AddressType, SaltType, SignatureType } from '@celo/utils/lib/io' +import { either } from 'fp-ts/lib/Either' +import * as t from 'io-ts' +import { isE164NumberStrict } from './phoneNumbers' + +export const E164PhoneNumberType = new t.Type( + 'E164Number', + t.string.is, + (input, context) => + either.chain(t.string.validate(input, context), (stringValue) => + isE164NumberStrict(stringValue) + ? t.success(stringValue) + : t.failure(stringValue, context, 'is not a valid e164 number') + ), + String +) +export const AttestationServiceStatusResponseType = t.type({ + status: t.literal('ok'), + smsProviders: t.array(t.string), + blacklistedRegionCodes: t.union([t.array(t.string), t.undefined]), + accountAddress: AddressType, + signature: t.union([SignatureType, t.undefined]), + version: t.string, + latestBlock: t.number, + ageOfLatestBlock: t.number, + isNodeSyncing: t.boolean, + appSignature: t.string, + smsProvidersRandomized: t.boolean, + maxDeliveryAttempts: t.number, + maxRerequestMins: t.number, + twilioVerifySidProvided: t.boolean, +}) + +export const AttestationServiceTestRequestType = t.type({ + phoneNumber: E164PhoneNumberType, + message: t.string, + signature: SignatureType, + provider: t.union([t.string, t.undefined]), +}) +export type AttestationServiceTestRequest = t.TypeOf + +export type E164Number = t.TypeOf + +export const AttestationRequestType = t.type({ + phoneNumber: E164PhoneNumberType, + account: AddressType, + issuer: AddressType, + // io-ts way of defining optional key-value pair + salt: t.union([t.undefined, SaltType]), + smsRetrieverAppSig: t.union([t.undefined, t.string]), + // if specified, the message sent will be short random number prefixed by this string + securityCodePrefix: t.union([t.undefined, t.string]), + language: t.union([t.undefined, t.string]), +}) + +export type AttestationRequest = t.TypeOf + +export const GetAttestationRequestType = t.type({ + phoneNumber: E164PhoneNumberType, + account: AddressType, + issuer: AddressType, + // io-ts way of defining optional key-value pair + salt: t.union([t.undefined, SaltType]), + // if the value supplied matches the stored security code, the response will include the complete message + securityCode: t.union([t.undefined, t.string]), +}) + +export type GetAttestationRequest = t.TypeOf + +export const AttestationResponseType = t.type({ + // Always returned in 1.0.x + success: t.boolean, + + // Returned for errors in 1.0.x + error: t.union([t.undefined, t.string]), + + // Stringifyed JSON dict of dicts, mapping attempt to error info. + errors: t.union([t.undefined, t.string]), + + // Returned for successful send in 1.0.x + provider: t.union([t.undefined, t.string]), + + // New fields + identifier: t.union([t.undefined, t.string]), + account: t.union([t.undefined, AddressType]), + issuer: t.union([t.undefined, AddressType]), + status: t.union([t.undefined, t.string]), + attempt: t.union([t.undefined, t.number]), + countryCode: t.union([t.undefined, t.string]), + + // Time to receive eventual delivery/failure (inc retries) + duration: t.union([t.undefined, t.number]), + + // Only used by test endpoint to return randomly generated salt. + // Never return a user-supplied salt. + salt: t.union([t.undefined, t.string]), + + // only returned if the request supplied the correct security code + attestationCode: t.union([t.undefined, t.string]), +}) + +export type AttestationResponse = t.TypeOf diff --git a/packages/sdk/phone-utils/src/phoneNumbers.test.ts b/packages/sdk/phone-utils/src/phoneNumbers.test.ts new file mode 100644 index 00000000000..feffdb3afee --- /dev/null +++ b/packages/sdk/phone-utils/src/phoneNumbers.test.ts @@ -0,0 +1,308 @@ +import { isE164Number } from '@celo/base/lib/phoneNumbers' +import { + getCountryCode, + getDisplayPhoneNumber, + getE164Number, + getExampleNumber, + getRegionCode, + getRegionCodeFromCountryCode, + parsePhoneNumber, + PhoneNumberUtils, +} from './phoneNumbers' + +const getPhoneHash = PhoneNumberUtils.getPhoneHash + +const COUNTRY_CODES = { + US: '+1', + DE: '+49', + AR: '+54', + MX: '+52', + LR: '+231', +} + +const TEST_PHONE_NUMBERS = { + VALID_US_1: '6282287826', + VALID_US_2: '(628) 228-7826', + VALID_US_3: '+16282287826', + VALID_US_4: '16282287826', + VALID_DE_1: '015229355106', + VALID_DE_2: '01522 (935)-5106', + VALID_DE_3: '+49 01522 935 5106', + VALID_AR_1: '091126431111', + VALID_AR_2: '(911) 2643-1111', + VALID_AR_3: '+5411 2643-1111', + VALID_AR_4: '9 11 2643 1111', + VALID_MX_1: '33 1234-5678', + VALID_MX_2: '1 33 1234-5678', + VALID_MX_3: '+52 1 33 1234-5678', + VALID_LR: '881551952', + FORMATTED_AR: '+5491126431111', + FORMATTED_MX: '+523312345678', + FORMATTED_LR: '+231881551952', + DISPLAY_AR: '9 11 2643-1111', + DISPLAY_MX: '33 1234 5678', + DISPLAY_LR: '88 155 1952', + INVALID_EMPTY: '', + TOO_SHORT: '123', + VALID_E164: '+141555544444', +} + +describe('Phone number formatting and utilities', () => { + describe('Phone hashing', () => { + it('Hashes an valid number without a salt', () => { + expect(getPhoneHash(TEST_PHONE_NUMBERS.VALID_E164)).toBe( + '0x483128504c69591aed5751690805ba9aad6c390644421dc189f6dbb6e085aadf' + ) + }) + it('Hashes an valid number with a salt', () => { + expect(getPhoneHash(TEST_PHONE_NUMBERS.VALID_E164, 'abcdefg')).toBe( + '0xf08257f6b126597dbd090fecf4f5106cfb59c98ef997644cef16f9349464810c' + ) + }) + it('Throws for an invalid number', () => { + try { + getPhoneHash(TEST_PHONE_NUMBERS.VALID_US_1) + fail('expected an error') + } catch (error) { + // Error expected + } + }) + }) + + describe('E164 formatting', () => { + it('Invalid empty', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.INVALID_EMPTY, COUNTRY_CODES.US)).toBe(null) + }) + it('Format US phone simple, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_US_1, COUNTRY_CODES.US)).toBe('+16282287826') + }) + it('Format US phone messy, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_US_2, COUNTRY_CODES.US)).toBe('+16282287826') + }) + it('Format US phone simple, with country code and wrong region', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_US_3, COUNTRY_CODES.AR)).toBe('+16282287826') + }) + it('Format US phone simple, with country code no plus', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_US_4, COUNTRY_CODES.US)).toBe('+16282287826') + }) + it('Format DE phone simple, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_DE_1, COUNTRY_CODES.DE)).toBe('+4915229355106') + }) + it('Format DE phone messy, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_DE_2, COUNTRY_CODES.DE)).toBe('+4915229355106') + }) + it('Format DE phone messy, wrong country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_DE_2, COUNTRY_CODES.US)).toBe(null) + }) + it('Format DE phone with country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_DE_3, COUNTRY_CODES.DE)).toBe('+4915229355106') + }) + it('Format AR phone simple, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_AR_1, COUNTRY_CODES.AR)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_AR + ) + }) + it('Format AR phone messy, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_AR_2, COUNTRY_CODES.AR)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_AR + ) + }) + it('Format AR phone with country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_AR_3, COUNTRY_CODES.AR)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_AR + ) + }) + + it('Format MX phone simple, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_MX_1, COUNTRY_CODES.MX)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_MX + ) + }) + it('Format MX phone simple with 1, no country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_MX_2, COUNTRY_CODES.MX)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_MX + ) + }) + it('Format MX phone with country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_MX_3, COUNTRY_CODES.MX)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_MX + ) + }) + + it('Format LR phone with country code', () => { + expect(getE164Number(TEST_PHONE_NUMBERS.VALID_LR, COUNTRY_CODES.LR)).toBe( + TEST_PHONE_NUMBERS.FORMATTED_LR + ) + }) + }) + + describe('Display formatting', () => { + it('Invalid empty', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.INVALID_EMPTY, COUNTRY_CODES.US)).toBe('') + }) + it('Format US phone simple, no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_US_1, COUNTRY_CODES.US)).toBe( + '(628) 228-7826' + ) + }) + it('Format US phone messy, no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_US_2, COUNTRY_CODES.US)).toBe( + '(628) 228-7826' + ) + }) + it('Format US phone simple, with country code and wrong region', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_US_3, COUNTRY_CODES.AR)).toBe( + '(628) 228-7826' + ) + }) + it('Format US phone simple, with country code but no param', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_US_3, COUNTRY_CODES.US)).toBe( + '(628) 228-7826' + ) + }) + it('Format US phone simple, with country code no plus', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_US_4, COUNTRY_CODES.US)).toBe( + '(628) 228-7826' + ) + }) + it('Format DE phone simple, no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_DE_1, COUNTRY_CODES.DE)).toBe( + '01522 9355106' + ) + }) + it('Format DE phone messy, no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_DE_2, COUNTRY_CODES.DE)).toBe( + '01522 9355106' + ) + }) + it('Format DE phone messy, wrong country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_DE_2, COUNTRY_CODES.US)).toBe( + TEST_PHONE_NUMBERS.VALID_DE_2 + ) + }) + it('Format DE phone with country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_DE_3, COUNTRY_CODES.DE)).toBe( + '01522 9355106' + ) + }) + it('Format AR phone simple, no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_AR_1, COUNTRY_CODES.AR)).toBe( + TEST_PHONE_NUMBERS.DISPLAY_AR + ) + }) + it('Format AR phone messy, no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_AR_2, COUNTRY_CODES.AR)).toBe( + TEST_PHONE_NUMBERS.DISPLAY_AR + ) + }) + it('Format AR phone with country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_AR_3, COUNTRY_CODES.AR)).toBe( + TEST_PHONE_NUMBERS.DISPLAY_AR + ) + }) + + it('Format MX phone with country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_MX_3, COUNTRY_CODES.MX)).toBe( + TEST_PHONE_NUMBERS.DISPLAY_MX + ) + }) + + it('Format LR phone with no country code', () => { + expect(getDisplayPhoneNumber(TEST_PHONE_NUMBERS.VALID_LR, COUNTRY_CODES.LR)).toBe( + TEST_PHONE_NUMBERS.DISPLAY_LR + ) + }) + }) + + describe('Number Parsing', () => { + it('Invalid empty', () => { + expect(parsePhoneNumber(TEST_PHONE_NUMBERS.INVALID_EMPTY, COUNTRY_CODES.US)).toBe(null) + }) + it('Too short', () => { + expect(parsePhoneNumber(TEST_PHONE_NUMBERS.TOO_SHORT, COUNTRY_CODES.US)).toBe(null) + }) + it('Format US messy phone #', () => { + expect(parsePhoneNumber(TEST_PHONE_NUMBERS.VALID_US_2, COUNTRY_CODES.US)).toMatchObject({ + e164Number: '+16282287826', + displayNumber: '(628) 228-7826', + countryCode: 1, + regionCode: 'US', + }) + }) + it('Format DE messy phone #', () => { + expect(parsePhoneNumber(TEST_PHONE_NUMBERS.VALID_DE_2, COUNTRY_CODES.DE)).toMatchObject({ + e164Number: '+4915229355106', + displayNumber: '01522 9355106', + countryCode: 49, + regionCode: 'DE', + }) + }) + it('Format AR messy phone # 1', () => { + expect(parsePhoneNumber(TEST_PHONE_NUMBERS.VALID_AR_4, COUNTRY_CODES.AR)).toMatchObject({ + e164Number: TEST_PHONE_NUMBERS.FORMATTED_AR, + displayNumber: TEST_PHONE_NUMBERS.DISPLAY_AR, + countryCode: 54, + regionCode: 'AR', + }) + }) + + it('Format MX phone # 1', () => { + expect(parsePhoneNumber(TEST_PHONE_NUMBERS.VALID_MX_1, COUNTRY_CODES.MX)).toMatchObject({ + e164Number: TEST_PHONE_NUMBERS.FORMATTED_MX, + displayNumber: TEST_PHONE_NUMBERS.DISPLAY_MX, + countryCode: 52, + regionCode: 'MX', + }) + }) + }) + + describe('Other phone helper methods', () => { + it('gets country code', () => { + expect(getCountryCode(TEST_PHONE_NUMBERS.VALID_US_3)).toBe(1) + expect(getCountryCode(TEST_PHONE_NUMBERS.VALID_DE_3)).toBe(49) + expect(getCountryCode(TEST_PHONE_NUMBERS.VALID_AR_3)).toBe(54) + }) + + it('gets region code', () => { + expect(getRegionCode(TEST_PHONE_NUMBERS.VALID_US_3)).toBe('US') + expect(getRegionCode(TEST_PHONE_NUMBERS.VALID_DE_3)).toBe('DE') + expect(getRegionCode(TEST_PHONE_NUMBERS.VALID_AR_3)).toBe('AR') + }) + + it('gets region code from country code', () => { + expect(getRegionCodeFromCountryCode(COUNTRY_CODES.US)).toBe('US') + expect(getRegionCodeFromCountryCode(COUNTRY_CODES.DE)).toBe('DE') + expect(getRegionCodeFromCountryCode(COUNTRY_CODES.AR)).toBe('AR') + }) + + it('checks if number is e164', () => { + // @ts-ignore + expect(isE164Number(null)).toBe(false) + expect(isE164Number('')).toBe(false) + expect(isE164Number(TEST_PHONE_NUMBERS.VALID_US_1)).toBe(false) + expect(isE164Number(TEST_PHONE_NUMBERS.VALID_US_2)).toBe(false) + expect(isE164Number(TEST_PHONE_NUMBERS.VALID_US_3)).toBe(true) + expect(isE164Number(TEST_PHONE_NUMBERS.VALID_US_4)).toBe(false) + }) + }) + + describe('Example phones', () => { + it('gets example by country showing zeros', () => { + expect(getExampleNumber(COUNTRY_CODES.AR)).toBe('000 0000-0000') + expect(getExampleNumber(COUNTRY_CODES.DE)).toBe('000 000000') + expect(getExampleNumber(COUNTRY_CODES.US)).toBe('(000) 000-0000') + }) + + it('gets example by country', () => { + expect(getExampleNumber(COUNTRY_CODES.AR, false)).toBe('011 2345-6789') + expect(getExampleNumber(COUNTRY_CODES.DE, false)).toBe('030 123456') + expect(getExampleNumber(COUNTRY_CODES.US, false)).toBe('(201) 555-0123') + }) + + it('gets example by country showing zeros in international way', () => { + expect(getExampleNumber(COUNTRY_CODES.AR, true, true)).toBe('+54 00 0000-0000') + expect(getExampleNumber(COUNTRY_CODES.DE, true, true)).toBe('+49 00 000000') + expect(getExampleNumber(COUNTRY_CODES.US, true, true)).toBe('+1 000-000-0000') + }) + }) +}) diff --git a/packages/sdk/phone-utils/src/phoneNumbers.ts b/packages/sdk/phone-utils/src/phoneNumbers.ts new file mode 100644 index 00000000000..b07fe1c9c39 --- /dev/null +++ b/packages/sdk/phone-utils/src/phoneNumbers.ts @@ -0,0 +1,248 @@ +import { isE164Number, ParsedPhoneNumber } from '@celo/base/lib/phoneNumbers' +import { + PhoneNumber, + PhoneNumberFormat, + PhoneNumberType, + PhoneNumberUtil, +} from 'google-libphonenumber' +import getPhoneHash from './getPhoneHash' + +const phoneUtil = PhoneNumberUtil.getInstance() +const MIN_PHONE_LENGTH = 4 + +export function getCountryCode(e164PhoneNumber: string) { + if (!e164PhoneNumber) { + return null + } + try { + return phoneUtil.parse(e164PhoneNumber).getCountryCode() + } catch (error) { + console.debug(`getCountryCode, number: ${e164PhoneNumber}, error: ${error}`) + return null + } +} + +export function getRegionCode(e164PhoneNumber: string) { + if (!e164PhoneNumber) { + return null + } + try { + return phoneUtil.getRegionCodeForNumber(phoneUtil.parse(e164PhoneNumber)) + } catch (error) { + console.debug(`getRegionCodeForNumber, number: ${e164PhoneNumber}, error: ${error}`) + return null + } +} + +export function getRegionCodeFromCountryCode(countryCode: string) { + if (!countryCode) { + return null + } + try { + return phoneUtil.getRegionCodeForCountryCode(parseInt(countryCode, 10)) + } catch (error) { + console.debug(`getRegionCodeFromCountryCode, countrycode: ${countryCode}, error: ${error}`) + return null + } +} + +export function getDisplayPhoneNumber(phoneNumber: string, defaultCountryCode: string) { + const phoneDetails = parsePhoneNumber(phoneNumber, defaultCountryCode) + if (phoneDetails) { + return phoneDetails.displayNumber + } else { + // Fallback to input instead of showing nothing for invalid numbers + return phoneNumber + } +} + +export function getDisplayNumberInternational(e164PhoneNumber: string) { + const countryCode = getCountryCode(e164PhoneNumber) + const phoneDetails = parsePhoneNumber(e164PhoneNumber, (countryCode || '').toString()) + if (phoneDetails) { + return phoneDetails.displayNumberInternational + } else { + // Fallback to input instead of showing nothing for invalid numbers + return e164PhoneNumber + } +} + +export function getE164DisplayNumber(e164PhoneNumber: string) { + const countryCode = getCountryCode(e164PhoneNumber) + return getDisplayPhoneNumber(e164PhoneNumber, (countryCode || '').toString()) +} + +export function getE164Number(phoneNumber: string, defaultCountryCode: string) { + const phoneDetails = parsePhoneNumber(phoneNumber, defaultCountryCode) + if (phoneDetails && isE164Number(phoneDetails.e164Number)) { + return phoneDetails.e164Number + } else { + return null + } +} + +// Actually runs through the parsing instead of using a regex +export function isE164NumberStrict(phoneNumber: string) { + try { + const parsedPhoneNumber = phoneUtil.parse(phoneNumber) + if (!phoneUtil.isValidNumber(parsedPhoneNumber)) { + return false + } + return phoneUtil.format(parsedPhoneNumber, PhoneNumberFormat.E164) === phoneNumber + } catch { + return false + } +} + +export function parsePhoneNumber( + phoneNumberRaw: string, + defaultCountryCode?: string +): ParsedPhoneNumber | null { + try { + if (!phoneNumberRaw || phoneNumberRaw.length < MIN_PHONE_LENGTH) { + return null + } + + const defaultRegionCode = defaultCountryCode + ? getRegionCodeFromCountryCode(defaultCountryCode) + : null + const parsedNumberUnfixed = phoneUtil.parse(phoneNumberRaw, defaultRegionCode || undefined) + const parsedCountryCode = parsedNumberUnfixed.getCountryCode() + const parsedRegionCode = phoneUtil.getRegionCodeForNumber(parsedNumberUnfixed) + const parsedNumber = handleSpecialCasesForParsing( + parsedNumberUnfixed, + parsedCountryCode, + parsedRegionCode + ) + + if (!parsedNumber) { + return null + } + + const isValid = phoneUtil.isValidNumberForRegion(parsedNumber, parsedRegionCode) + + return isValid + ? { + e164Number: phoneUtil.format(parsedNumber, PhoneNumberFormat.E164), + displayNumber: handleSpecialCasesForDisplay(parsedNumber, parsedCountryCode), + displayNumberInternational: phoneUtil.format( + parsedNumber, + PhoneNumberFormat.INTERNATIONAL + ), + countryCode: parsedCountryCode, + regionCode: parsedRegionCode, + } + : null + } catch (error) { + console.debug(`phoneNumbers/parsePhoneNumber/Failed to parse phone number, error: ${error}`) + return null + } +} + +function handleSpecialCasesForParsing( + parsedNumber: PhoneNumber, + countryCode?: number, + regionCode?: string +) { + if (!countryCode || !regionCode) { + return parsedNumber + } + + switch (countryCode) { + // Argentina + // https://github.com/googlei18n/libphonenumber/blob/master/FAQ.md#why-is-this-number-from-argentina-ar-or-mexico-mx-not-identified-as-the-right-number-type + // https://en.wikipedia.org/wiki/Telephone_numbers_in_Argentina + case 54: + return prependToFormMobilePhoneNumber(parsedNumber, regionCode, '9') + + default: + return parsedNumber + } +} + +// TODO(Rossy) Given the inconsistencies of numbers around the world, we should +// display e164 everywhere to ensure users knows exactly who their sending money to +function handleSpecialCasesForDisplay(parsedNumber: PhoneNumber, countryCode?: number) { + switch (countryCode) { + // Argentina + // The Google lib formatter incorretly adds '15' to the nationally formatted number for Argentina + // However '15' is only needed when calling a mobile from a landline + case 54: + return phoneUtil + .format(parsedNumber, PhoneNumberFormat.INTERNATIONAL) + .replace(/\+54(\s)?/, '') + + case 231: + const formatted = phoneUtil.format(parsedNumber, PhoneNumberFormat.NATIONAL) + return formatted && formatted[0] === '0' ? formatted.slice(1) : formatted + + default: + return phoneUtil.format(parsedNumber, PhoneNumberFormat.NATIONAL) + } +} + +/** + * Some countries require a prefix before the area code depending on if the number is + * mobile vs landline and international vs national + */ + +function prependToFormMobilePhoneNumber( + parsedNumber: PhoneNumber, + regionCode: string, + prefix: string +) { + if (phoneUtil.getNumberType(parsedNumber) === PhoneNumberType.MOBILE) { + return parsedNumber + } + + let nationalNumber = phoneUtil.format(parsedNumber, PhoneNumberFormat.NATIONAL) + // Nationally formatted numbers sometimes contain leading 0 + if (nationalNumber.charAt(0) === '0') { + nationalNumber = nationalNumber.slice(1) + } + // If the number already starts with prefix, don't prepend it again + if (nationalNumber.startsWith(prefix)) { + return null + } + + const adjustedNumber = phoneUtil.parse(prefix + nationalNumber, regionCode) + return phoneUtil.getNumberType(adjustedNumber) === PhoneNumberType.MOBILE ? adjustedNumber : null +} + +export function getExampleNumber( + regionCode: string, + useOnlyZeroes: boolean = true, + isInternational: boolean = false +) { + const examplePhone = phoneUtil.getExampleNumber( + getRegionCodeFromCountryCode(regionCode) as string + ) + + if (!examplePhone) { + return + } + + const formatedExample = phoneUtil.format( + examplePhone, + isInternational ? PhoneNumberFormat.INTERNATIONAL : PhoneNumberFormat.NATIONAL + ) + + if (useOnlyZeroes) { + if (isInternational) { + return formatedExample.replace(/(^\+[0-9]{1,3} |[0-9])/g, (value, _, i) => (i ? '0' : value)) + } + return formatedExample.replace(/[0-9]/g, '0') + } + + return formatedExample +} + +export const PhoneNumberUtils = { + getPhoneHash, + getCountryCode, + getRegionCode, + getDisplayPhoneNumber, + getE164Number, + isE164Number, + parsePhoneNumber, +} diff --git a/packages/sdk/phone-utils/tsconfig.json b/packages/sdk/phone-utils/tsconfig.json new file mode 100644 index 00000000000..8a3e89de4de --- /dev/null +++ b/packages/sdk/phone-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@celo/typescript/tsconfig.library.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "downlevelIteration": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/sdk/phone-utils/tslint.json b/packages/sdk/phone-utils/tslint.json new file mode 100644 index 00000000000..7221d56e375 --- /dev/null +++ b/packages/sdk/phone-utils/tslint.json @@ -0,0 +1,8 @@ +{ + "extends": ["@celo/typescript/tslint.json"], + "rules": { + "no-global-arrow-functions": false, + "no-console": false, + "no-floating-promises": true + } +} diff --git a/packages/sdk/utils/src/countries.ts b/packages/sdk/utils/src/countries.ts index e5ffe3319cc..98d88b361ef 100644 --- a/packages/sdk/utils/src/countries.ts +++ b/packages/sdk/utils/src/countries.ts @@ -7,6 +7,9 @@ interface CountryNames { [name: string]: string } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export interface LocalizedCountry extends Omit { displayName: string displayNameNoDiacritics: string @@ -34,6 +37,9 @@ const matchCountry = (country: LocalizedCountry, query: string) => { ) } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export class Countries { language: string countryMap: Map diff --git a/packages/sdk/utils/src/io.ts b/packages/sdk/utils/src/io.ts index 5b4bf3ebd8e..f8c18f3f988 100644 --- a/packages/sdk/utils/src/io.ts +++ b/packages/sdk/utils/src/io.ts @@ -36,6 +36,9 @@ export const JSONStringType = new t.Type( String ) +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export const E164PhoneNumberType = new t.Type( 'E164Number', t.string.is, @@ -76,6 +79,9 @@ export const SignatureType = t.string export const SaltType = t.string +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export const AttestationServiceStatusResponseType = t.type({ status: t.literal('ok'), smsProviders: t.array(t.string), @@ -93,18 +99,32 @@ export const AttestationServiceStatusResponseType = t.type({ twilioVerifySidProvided: t.boolean, }) +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export const AttestationServiceTestRequestType = t.type({ phoneNumber: E164PhoneNumberType, message: t.string, signature: SignatureType, provider: t.union([t.string, t.undefined]), }) + +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export type AttestationServiceTestRequest = t.TypeOf export type Signature = t.TypeOf export type Address = t.TypeOf + +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export type E164Number = t.TypeOf +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export const AttestationRequestType = t.type({ phoneNumber: E164PhoneNumberType, account: AddressType, @@ -117,8 +137,14 @@ export const AttestationRequestType = t.type({ language: t.union([t.undefined, t.string]), }) +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export type AttestationRequest = t.TypeOf +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export const GetAttestationRequestType = t.type({ phoneNumber: E164PhoneNumberType, account: AddressType, @@ -129,6 +155,9 @@ export const GetAttestationRequestType = t.type({ securityCode: t.union([t.undefined, t.string]), }) +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export type GetAttestationRequest = t.TypeOf export const AttestationResponseType = t.type({ diff --git a/packages/sdk/utils/src/phoneNumbers.ts b/packages/sdk/utils/src/phoneNumbers.ts index 57aa1c9ae06..866876ff419 100644 --- a/packages/sdk/utils/src/phoneNumbers.ts +++ b/packages/sdk/utils/src/phoneNumbers.ts @@ -14,6 +14,7 @@ import { soliditySha3 } from 'web3-utils' // Exports moved to @celo/base, forwarding them // here for backwards compatibility + export { anonymizedPhone, isE164Number, ParsedPhoneNumber } from '@celo/base/lib/phoneNumbers' const sha3 = (v: string): string | null => soliditySha3({ type: 'string', value: v }) @@ -24,6 +25,9 @@ export const getPhoneHash = (phoneNumber: string, salt?: string): string => { const phoneUtil = PhoneNumberUtil.getInstance() const MIN_PHONE_LENGTH = 4 +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getCountryEmoji( e164PhoneNumber: string, countryCodePossible?: number, @@ -44,7 +48,9 @@ export function getCountryEmoji( return country ? country.emoji : '' } - +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getCountryCode(e164PhoneNumber: string) { if (!e164PhoneNumber) { return null @@ -56,7 +62,9 @@ export function getCountryCode(e164PhoneNumber: string) { return null } } - +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getRegionCode(e164PhoneNumber: string) { if (!e164PhoneNumber) { return null @@ -68,7 +76,9 @@ export function getRegionCode(e164PhoneNumber: string) { return null } } - +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getRegionCodeFromCountryCode(countryCode: string) { if (!countryCode) { return null @@ -81,6 +91,9 @@ export function getRegionCodeFromCountryCode(countryCode: string) { } } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getDisplayPhoneNumber(phoneNumber: string, defaultCountryCode: string) { const phoneDetails = parsePhoneNumber(phoneNumber, defaultCountryCode) if (phoneDetails) { @@ -91,6 +104,9 @@ export function getDisplayPhoneNumber(phoneNumber: string, defaultCountryCode: s } } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getDisplayNumberInternational(e164PhoneNumber: string) { const countryCode = getCountryCode(e164PhoneNumber) const phoneDetails = parsePhoneNumber(e164PhoneNumber, (countryCode || '').toString()) @@ -102,11 +118,17 @@ export function getDisplayNumberInternational(e164PhoneNumber: string) { } } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getE164DisplayNumber(e164PhoneNumber: string) { const countryCode = getCountryCode(e164PhoneNumber) return getDisplayPhoneNumber(e164PhoneNumber, (countryCode || '').toString()) } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getE164Number(phoneNumber: string, defaultCountryCode: string) { const phoneDetails = parsePhoneNumber(phoneNumber, defaultCountryCode) if (phoneDetails && isE164Number(phoneDetails.e164Number)) { @@ -117,6 +139,9 @@ export function getE164Number(phoneNumber: string, defaultCountryCode: string) { } // Actually runs through the parsing instead of using a regex +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function isE164NumberStrict(phoneNumber: string) { try { const parsedPhoneNumber = phoneUtil.parse(phoneNumber) @@ -129,6 +154,9 @@ export function isE164NumberStrict(phoneNumber: string) { } } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function parsePhoneNumber( phoneNumberRaw: string, defaultCountryCode?: string @@ -197,6 +225,9 @@ function handleSpecialCasesForParsing( // TODO(Rossy) Given the inconsistencies of numbers around the world, we should // display e164 everywhere to ensure users knows exactly who their sending money to +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ function handleSpecialCasesForDisplay(parsedNumber: PhoneNumber, countryCode?: number) { switch (countryCode) { // Argentina @@ -220,7 +251,9 @@ function handleSpecialCasesForDisplay(parsedNumber: PhoneNumber, countryCode?: n * Some countries require a prefix before the area code depending on if the number is * mobile vs landline and international vs national */ - +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ function prependToFormMobilePhoneNumber( parsedNumber: PhoneNumber, regionCode: string, @@ -244,6 +277,9 @@ function prependToFormMobilePhoneNumber( return phoneUtil.getNumberType(adjustedNumber) === PhoneNumberType.MOBILE ? adjustedNumber : null } +/** + * @deprecated moved to @celo/phone-utils will be removed in next major version + */ export function getExampleNumber( regionCode: string, useOnlyZeroes: boolean = true,