From 2e33cd7cc390593f26baf1d85016b9294416c886 Mon Sep 17 00:00:00 2001 From: MichaelBrew Date: Sun, 7 Aug 2022 15:21:30 -0500 Subject: [PATCH] Add support to scan QR code for hotspot transfer and activity validity check --- src/features/wallet/send/SendScreen.tsx | 13 +- src/features/wallet/send/SendView.tsx | 15 +- src/navigation/navigator.ts | 5 +- src/providers/AppLinkProvider.tsx | 338 +++++++++++++++++------- src/providers/appLinkTypes.ts | 10 +- 5 files changed, 279 insertions(+), 102 deletions(-) diff --git a/src/features/wallet/send/SendScreen.tsx b/src/features/wallet/send/SendScreen.tsx index 30d35b58b..0993266f1 100644 --- a/src/features/wallet/send/SendScreen.tsx +++ b/src/features/wallet/send/SendScreen.tsx @@ -22,9 +22,15 @@ const SendScreen = ({ route }: Props) => { const rootNavigation = useNavigation() const tabNavigation = useNavigation() const scanResult = route?.params?.scanResult - const hotspotAddress = route?.params?.hotspotAddress - const isSeller = route?.params?.isSeller const isPinVerified = route?.params?.pinVerified + + let { hotspotAddress, isSeller, type } = route?.params ?? {} + if (scanResult?.hotspotAddress) { + hotspotAddress = scanResult.hotspotAddress as string + } + if (scanResult?.isSeller) isSeller = scanResult.isSeller as boolean + if (scanResult?.type) type = scanResult.type + const isPinRequiredForPayment = useSelector( (state: RootState) => state.app.isPinRequiredForPayment, ) @@ -34,8 +40,9 @@ const SendScreen = ({ route }: Props) => { const permanentPaymentAddress = useSelector( (state: RootState) => state.app.permanentPaymentAddress, ) + // If "Deploy Mode" is enabled, only allow payment transactions - const type = isDeployModeEnabled ? 'payment' : route?.params?.type + if (isDeployModeEnabled) type = 'payment' // If "Deploy Mode" is enabled without a permanent payment address, disable all payments const isDeployModePaymentsDisabled = isDeployModeEnabled && !permanentPaymentAddress diff --git a/src/features/wallet/send/SendView.tsx b/src/features/wallet/send/SendView.tsx index 1f83e94e9..86db58610 100644 --- a/src/features/wallet/send/SendView.tsx +++ b/src/features/wallet/send/SendView.tsx @@ -62,6 +62,7 @@ import { AppLink, AppLinkPayment, AppLinkCategoryType, + AppLinkTransfer, } from '../../../providers/appLinkTypes' import { MainTabNavigationProp } from '../../../navigation/main/tabTypes' import { isDataOnly } from '../../../utils/hotspotUtils' @@ -160,7 +161,9 @@ const SendView = ({ useAsync(async () => { if (type === 'transfer' && hotspotAddress && blockHeight) { const gateway = await getHotspotDetails(hotspotAddress) - if (isDataOnly(gateway)) { + const canSkipActivityCheck = + isDataOnly(gateway) || scanResult?.skipActivityCheck + if (canSkipActivityCheck) { setLastReportedActivity('') setHasValidActivity(true) setStalePocBlockCount(0) @@ -237,6 +240,11 @@ const SendView = ({ ): scanRes is AppLinkPayment => { return scanRes.type === 'payment' && scanRes.payees !== undefined } + const isAppLinkTransfer = ( + scanRes: AppLink | AppLinkPayment | AppLinkTransfer, + ): scanRes is AppLinkTransfer => { + return scanRes.type === 'transfer' + } if (isAppLinkPayment(scanResult)) { scannedSendDetails = scanResult.payees.map( ({ address, amount: scanAmount, memo = '' }, i) => { @@ -259,7 +267,10 @@ const SendView = ({ scannedSendDetails = [ { id: 'transfer0', - address: scanResult.address, + address: + isAppLinkTransfer(scanResult) && scanResult.newOwnerAddress + ? scanResult.newOwnerAddress + : scanResult.address, addressAlias: '', addressLoading: false, amount, diff --git a/src/navigation/navigator.ts b/src/navigation/navigator.ts index 52e6805bf..aba42519e 100644 --- a/src/navigation/navigator.ts +++ b/src/navigation/navigator.ts @@ -4,6 +4,7 @@ import { LockScreenRequestType } from './main/tabTypes' import { AppLink, AppLinkPayment, + AppLinkTransfer, LinkWalletRequest, SignHotspotRequest, } from '../providers/appLinkTypes' @@ -17,7 +18,9 @@ const lock = (params: { navigationRef.current?.navigate('LockScreen', params) } -const send = (params: { scanResult: AppLink | AppLinkPayment }) => { +const send = (params: { + scanResult: AppLink | AppLinkPayment | AppLinkTransfer +}) => { navigationRef.current?.navigate('Send', params) } diff --git a/src/providers/AppLinkProvider.tsx b/src/providers/AppLinkProvider.tsx index f154fd175..4d95bcaf9 100644 --- a/src/providers/AppLinkProvider.tsx +++ b/src/providers/AppLinkProvider.tsx @@ -22,8 +22,8 @@ import { AppLinkCategories, AppLinkCategoryType, AppLinkPayment, - Payee, AppLinkLocation, + AppLinkTransfer, LinkWalletRequest, SignHotspotRequest, } from './appLinkTypes' @@ -76,6 +76,140 @@ const assertCurrentSenderAddress = async (senderAddress: string) => { throw new MismatchedAddressError(AddressType.SenderAddress) } } +async function assertPaymentAddresses(data: AppLinkPayment) { + if (data.senderAddress) { + // If a senderAddress is provided, ensure that it's both a valid wallet address and that + // it matches the current wallet address + assertValidAddress(data.senderAddress, AddressType.SenderAddress) + await assertCurrentSenderAddress(data.senderAddress) + } + if (data.payees?.length > 0) { + data.payees.forEach(({ address }) => + assertValidAddress(address, AddressType.ReceiverAddress), + ) + } +} + +// ScanDataType describes both the scanned data format as well as intended use. +// For example, multiple types are intended to pre-fill the payment "send" form but can provide +// varying levels of information (like single or multiple recipients, optional memo fields, etc). +enum ScanDataType { + // Deeplink to somewhere in the app + DEEPLINK, + // Hotspot address update + LOCATION_UPDATE, + // DC burn + DC_BURN, + // Hotspot transfer + TRANSFER, + // Hotspot transfer with only address provided + TRANSFER_ADDRESS_ONLY, + // Payment + PAYMENT, + // Payment with only address provided + PAYMENT_ADDRESS_ONLY, + // Payment to multiple recipients + PAYMENT_MULTI, + // Payment to multiple recipients and optional memos + PAYMENT_MULTI_MEMO, +} + +function isDeeplink(data: string) { + try { + const parsed = queryString.parseUrl(data) + return ( + parsed.url.includes(APP_LINK_PROTOCOL) && + parsed.url.includes(UNIVERSAL_LINK_BASE) && + parsed.url.includes(UNIVERSAL_LINK_WWW_BASE) + ) + } catch (err) {} +} + +function isLocationUpdate(data: string) { + try { + const dataObj = JSON.parse(data) + return dataObj.lat && dataObj.lng && dataObj.address + } catch (err) {} +} + +function isDcBurn(data: string, scanType?: AppLinkCategoryType) { + try { + const dataObj = JSON.parse(data) + const type = dataObj.type || scanType + return type === 'dc_burn' && dataObj.address + } catch (err) {} +} + +function isTransfer(data: string, scanType?: AppLinkCategoryType) { + try { + const dataObj = JSON.parse(data) + const type = dataObj.type || scanType + return type === 'transfer' && dataObj.newOwnerAddress + } catch (err) {} +} + +function isTransferAddressOnly(data: string, scanType?: AppLinkCategoryType) { + return scanType === 'transfer' && Address.isValid(data) +} + +function isPayment(data: string, scanType?: AppLinkCategoryType) { + try { + const dataObj = JSON.parse(data) + const type = dataObj.type || scanType + return type === 'payment' && dataObj.address + } catch (err) {} +} + +function isPaymentAddressOnly(data: string, scanType?: AppLinkCategoryType) { + return scanType === 'payment' && Address.isValid(data) +} + +function isPaymentMulti(data: string, scanType?: AppLinkCategoryType) { + // data = { payees: { [payeeAddress]: amount } } + type Payees = Record + + try { + const dataObj = JSON.parse(data) + const type = dataObj.type || scanType + return ( + type === 'payment' && + dataObj.payees && + typeof Object.values(dataObj.payees as Payees)[0] === 'number' + ) + } catch (err) {} +} + +function isPaymentMultiMemo(data: string, scanType?: AppLinkCategoryType) { + // data = { payees: { [payeeAddress]: { amount: number, memo?: string } } } + type Payees = Record + + try { + const dataObj = JSON.parse(data) + const type = dataObj.type || scanType + return ( + type === 'payment' && + dataObj.payees && + typeof Object.values(dataObj.payees as Payees)[0]?.amount === 'number' + ) + } catch (err) {} +} + +function getDataScanType( + data: string, + scanType?: AppLinkCategoryType, +): ScanDataType | undefined { + if (isDeeplink(data)) return ScanDataType.DEEPLINK + if (isLocationUpdate(data)) return ScanDataType.LOCATION_UPDATE + if (isDcBurn(data, scanType)) return ScanDataType.DC_BURN + if (isTransfer(data, scanType)) return ScanDataType.TRANSFER + if (isTransferAddressOnly(data, scanType)) + return ScanDataType.TRANSFER_ADDRESS_ONLY + if (isPayment(data, scanType)) return ScanDataType.PAYMENT + if (isPaymentAddressOnly(data, scanType)) + return ScanDataType.PAYMENT_ADDRESS_ONLY + if (isPaymentMulti(data, scanType)) return ScanDataType.PAYMENT_MULTI + if (isPaymentMultiMemo(data, scanType)) return ScanDataType.PAYMENT_MULTI_MEMO +} export const createAppLink = ( resource: AppLinkCategoryType, @@ -117,6 +251,7 @@ const useAppLink = () => { | AppLink | AppLinkPayment | AppLinkLocation + | AppLinkTransfer | LinkWalletRequest | SignHotspotRequest, ) => { @@ -137,7 +272,9 @@ const useAppLink = () => { case 'dc_burn': case 'payment': case 'transfer': - navigator.send({ scanResult: record as AppLink | AppLinkPayment }) + navigator.send({ + scanResult: record as AppLink | AppLinkPayment | AppLinkTransfer, + }) break case 'add_gateway': { @@ -229,123 +366,130 @@ const useAppLink = () => { } catch (err) {} } - /** - * The data scanned from the QR code is expected to be one of these possibilities: - * (1) A helium deeplink URL - * (2) A lat/lng pair + hotspot address for hotspot location updates - * (3) address string - * (4) stringified JSON object { type, senderAddress?, address, amount?, memo? } - * (5) stringified JSON object { type, senderAddress?, payees: {[payeeAddress]: amount} } - * (6) stringified JSON object { type, senderAddress?, payees: {[payeeAddress]: { amount, memo? }} } - */ const parseBarCodeData = useCallback( async ( data: string, scanType: AppLinkCategoryType, - ): Promise => { - // Case (1) helium deeplink URL - const urlParams = parseUrl(data) - if (urlParams) { - return urlParams + ): Promise< + AppLink | AppLinkPayment | AppLinkLocation | AppLinkTransfer + > => { + if (!data) throw new Error('Missing required data') + const scanDataType = getDataScanType(data, scanType) + + if (scanDataType === ScanDataType.DEEPLINK) { + return parseUrl(data) as AppLink } - // Case (2) lat/lng pair - const location = parseLocation(data) - if (location) { - assertValidAddress(location.hotspotAddress) + if (scanDataType === ScanDataType.LOCATION_UPDATE) { + const location = parseLocation(data) as AppLinkLocation + assertValidAddress(location.hotspotAddress, AddressType.HotspotAddress) return location } - // Case (3) address string - if (Address.isValid(data)) { - if (scanType === 'transfer') { - return { - type: scanType, - address: data, - } - } - return { - type: scanType, - payees: [{ address: data }], - } - } - - const rawScanResult = JSON.parse(data) - const type = rawScanResult.type || scanType - - if (type === 'dc_burn') { - // Case (4) stringified JSON { type, address, amount?, memo? } + if (scanDataType === ScanDataType.DC_BURN) { + const rawScanResult = JSON.parse(data) const scanResult: AppLink = { - type, + type: 'dc_burn', address: rawScanResult.address, amount: rawScanResult.amount, memo: rawScanResult.memo, } + // TODO: Validate sender ownership? assertValidAddress(scanResult.address, AddressType.SenderAddress) return scanResult } - if (type === 'payment') { - let scanResult: AppLinkPayment - if (rawScanResult.address) { - // Case (4) stringified JSON { type, senderAddress?, address, amount?, memo? } - scanResult = { - type, - senderAddress: rawScanResult.senderAddress, - payees: [ - { - address: rawScanResult.address, - amount: rawScanResult.amount, - memo: rawScanResult.memo, - }, - ], - } - } else if (rawScanResult.payees) { - scanResult = { - type, - senderAddress: rawScanResult.senderAddress, - payees: Object.entries(rawScanResult.payees).map((entries) => { - let amount - let memo - if (entries[1]) { - if (typeof entries[1] === 'number') { - // Case (5) stringified JSON object { type, senderAddress?, payees: {[payeeAddress]: amount} } - amount = entries[1] as number - } else if (typeof entries[1] === 'object') { - // Case (6) stringified JSON object { type, senderAddress?, payees: {[payeeAddress]: { amount, memo? }} } - const scanData = entries[1] as { - amount: string - memo?: string - } - amount = scanData.amount - memo = scanData.memo - } - } - return { - address: entries[0], - amount: `${amount}`, - memo, - } as Payee - }), - } - } else { - throw new Error('Unrecognized payload for payment scan') + if (scanDataType === ScanDataType.TRANSFER) { + const rawScanResult = JSON.parse(data) + const scanResult: AppLinkTransfer = { + type: 'transfer', + newOwnerAddress: rawScanResult.newOwnerAddress, + hotspotAddress: rawScanResult.hotspotAddress, + skipActivityCheck: rawScanResult.skipActivityCheck, + isSeller: rawScanResult.isSeller, } - - if (scanResult.senderAddress) { - // If a senderAddress is provided, ensure that it's both a valid wallet address and that - // it matches the current wallet address + // TODO: Validate sender ownership? + assertValidAddress( + scanResult.newOwnerAddress, + AddressType.ReceiverAddress, + ) + if (scanResult.hotspotAddress) { assertValidAddress( - scanResult.senderAddress, - AddressType.SenderAddress, + scanResult.hotspotAddress, + AddressType.HotspotAddress, ) - await assertCurrentSenderAddress(scanResult.senderAddress) } - scanResult.payees.forEach(({ address }) => - assertValidAddress(address, AddressType.ReceiverAddress), + return scanResult + } + + if (scanDataType === ScanDataType.TRANSFER_ADDRESS_ONLY) { + const scanResult: AppLinkTransfer = { + type: 'transfer', + newOwnerAddress: data, + } + // TODO: Validate sender ownership? + assertValidAddress( + scanResult.newOwnerAddress, + AddressType.ReceiverAddress, ) return scanResult } + + if (scanDataType === ScanDataType.PAYMENT) { + const rawScanResult = JSON.parse(data) + const scanResult: AppLinkPayment = { + type: 'payment', + senderAddress: rawScanResult.senderAddress, + payees: [ + { + address: rawScanResult.address, + amount: rawScanResult.amount, + memo: rawScanResult.memo, + }, + ], + } + await assertPaymentAddresses(scanResult) + return scanResult + } + + if (scanDataType === ScanDataType.PAYMENT_ADDRESS_ONLY) { + const scanResult: AppLinkPayment = { + type: scanType, + payees: [{ address: data }], + } + await assertPaymentAddresses(scanResult) + return scanResult + } + + if (scanDataType === ScanDataType.PAYMENT_MULTI) { + const rawScanResult = JSON.parse(data) + const scanResult: AppLinkPayment = { + type: 'payment', + senderAddress: rawScanResult.senderAddress, + payees: Object.entries(rawScanResult.payees).map((entries) => ({ + address: entries[0], + amount: entries[1] as number, + })), + } + await assertPaymentAddresses(scanResult) + return scanResult + } + + if (scanDataType === ScanDataType.PAYMENT_MULTI_MEMO) { + const rawScanResult = JSON.parse(data) + const scanResult: AppLinkPayment = { + type: 'payment', + senderAddress: rawScanResult.senderAddress, + payees: Object.entries(rawScanResult.payees).map((entries) => ({ + address: entries[0], + amount: (entries[1] as { amount?: number; memo?: string }).amount, + memo: (entries[1] as { amount?: number; memo?: string }).memo, + })), + } + await assertPaymentAddresses(scanResult) + return scanResult + } + throw new Error('Unknown scan type') }, [parseUrl], @@ -357,7 +501,11 @@ const useAppLink = () => { scanType: AppLinkCategoryType, opts?: Record, assertScanResult?: ( - scanResult: AppLink | AppLinkPayment | AppLinkLocation, + scanResult: + | AppLink + | AppLinkPayment + | AppLinkLocation + | AppLinkTransfer, ) => void, ) => { const scanResult = await parseBarCodeData(data, scanType) diff --git a/src/providers/appLinkTypes.ts b/src/providers/appLinkTypes.ts index 0f56b3c00..271848f06 100644 --- a/src/providers/appLinkTypes.ts +++ b/src/providers/appLinkTypes.ts @@ -23,7 +23,15 @@ export type AppLink = { address: string amount?: string | number memo?: string - [key: string]: string | number | undefined + [key: string]: string | number | boolean | undefined +} + +export type AppLinkTransfer = { + type: 'transfer' + newOwnerAddress: string + hotspotAddress?: string + skipActivityCheck?: boolean + isSeller?: boolean } export type AppLinkPayment = {