diff --git a/CHANGELOG.md b/CHANGELOG.md index d447acd8000..9cb6e5ad84f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## 4.49.0 (staging) +- added: Honor `af` affiliate parameter on `deep.edge.app` deep links, activating the promotion alongside any inner payload (e.g. private-key import). - added: Show swap KYC/terms modal for NExchange - added: Nym mixnet warning in Stake, Unstake, and Claim Rewards scenes - changed: Migrate Thorchain Savers and Thorchain Yield endpoints off NineRealms to gateway.liquify.com. diff --git a/src/__tests__/DeepLink.test.ts b/src/__tests__/DeepLink.test.ts index c4e2d359360..66801e81047 100644 --- a/src/__tests__/DeepLink.test.ts +++ b/src/__tests__/DeepLink.test.ts @@ -321,10 +321,53 @@ describe('parseDeepLink', function () { 'https://dl.edge.app': { type: 'promotion', installerId: '' + }, + 'https://deep.edge.app/?af=bob': { + type: 'promotion', + installerId: 'bob' + }, + 'https://deep.edge.app/promotion/bob?af=bob': { + type: 'promotion', + installerId: 'bob' } }) }) + describe('affiliate', function () { + makeLinkTests({ + 'https://deep.edge.app/pay/bitcoincash/abc123?af=zano-telegram': { + type: 'affiliate', + installerId: 'zano-telegram', + link: { + type: 'other', + protocol: 'bitcoincash', + uri: 'bitcoincash:abc123' + } + }, + 'https://deep.edge.app/plugin/simplex/rabbit/hole?af=bob¶m=alice': { + type: 'affiliate', + installerId: 'bob', + link: { + type: 'plugin', + pluginId: 'simplex', + path: '/rabbit/hole', + query: { param: 'alice' } + } + } + }) + + // Lookalike hosts must NOT be treated as deep.edge.app: + it('https://deep.edge.appsomething.com/?af=evil', () => { + expect( + parseDeepLink('https://deep.edge.appsomething.com/?af=evil') + ).toEqual({ + type: 'other', + protocol: 'https', + uri: 'https://deep.edge.appsomething.com/?af=evil' + }) + }) + }) + describe('swap', () => { makeLinkTests({ 'edge://swap': { diff --git a/src/actions/DeepLinkingActions.tsx b/src/actions/DeepLinkingActions.tsx index 3131b3e45a7..1a2661da9ed 100644 --- a/src/actions/DeepLinkingActions.tsx +++ b/src/actions/DeepLinkingActions.tsx @@ -182,6 +182,11 @@ async function handleLink( await dispatch(activatePromotion(link.installerId ?? '')) break + case 'affiliate': + await dispatch(activatePromotion(link.installerId)) + await handleLink(navigation, dispatch, state, link.link) + break + case 'requestAddress': await doRequestAddress(navigation, state.core.account, dispatch, link) break diff --git a/src/types/DeepLinkTypes.ts b/src/types/DeepLinkTypes.ts index 011812adc69..0d778106d19 100644 --- a/src/types/DeepLinkTypes.ts +++ b/src/types/DeepLinkTypes.ts @@ -23,6 +23,11 @@ * - https://dl.edge.app/... = edge://promotion/... * - https://dl.edge.app/?af=... = edge://promotion/... * + * `deep.edge.app` URLs may also carry an `?af=` query parameter. + * When present alongside another payload (e.g. a `pay` private-key URI), the + * deep link resolves to an `affiliate` wrapper that activates the promotion + * and then delegates to the inner link. + * * We also support some legacy prefixes (but don't use these): * * - edge-ret://plugins/simplex/... = edge://plugin/simplex/... @@ -147,7 +152,14 @@ export interface ModalLink { modalName: ModalNames } +export interface AffiliateLink { + type: 'affiliate' + installerId: string + link: DeepLink +} + export type DeepLink = + | AffiliateLink | AztecoLink | SceneLink | EdgeLoginLink diff --git a/src/util/DeepLinkParser.ts b/src/util/DeepLinkParser.ts index 3d500d37c0f..aaeeda41b96 100644 --- a/src/util/DeepLinkParser.ts +++ b/src/util/DeepLinkParser.ts @@ -26,6 +26,19 @@ export function parseDeepLink( ): DeepLink { const { aztecoApiKey = ENV.AZTECO_API_KEY } = opts + // Extract an `af` affiliate installer id from `deep.edge.app` URLs before + // the prefix normalization below strips the host. Matches the `dl.edge.app` + // behavior and adds a wrapper when there is also an inner payload: + const affiliateSplit = splitAffiliateLink(uri) + if (affiliateSplit != null) { + const { installerId, remainingUri } = affiliateSplit + const inner = parseDeepLink(remainingUri, opts) + if (inner.type === 'promotion' || inner.type === 'noop') { + return { type: 'promotion', installerId } + } + return { type: 'affiliate', installerId, link: inner } + } + // Normalize some legacy cases: for (const prefix of prefixes) { const [from, to] = prefix @@ -351,3 +364,31 @@ const prefixes: Array<[string, string]> = [ ['airbitz://', 'edge://'], ['reqaddr://', 'edge://reqaddr'] ] + +/** + * Detect an `af` affiliate installer id on a `deep.edge.app` URL and return + * the extracted id plus the original URL with `af` stripped from the query. + * Returns `null` for every other input. + */ +function splitAffiliateLink( + uri: string +): { installerId: string; remainingUri: string } | null { + if (!uri.startsWith('https://')) return null + + const url = new URL(uri) + if (url.host !== 'deep.edge.app') return null + const query = parseQuery(url.query) + const { af } = query + if (af == null || af === '') return null + + const remainingQuery: typeof query = {} + for (const key of Object.keys(query)) { + if (key !== 'af') remainingQuery[key] = query[key] + } + const queryString = + Object.keys(remainingQuery).length === 0 + ? '' + : stringifyQuery(remainingQuery) + const remainingUri = `${url.protocol}//${url.host}${url.pathname}${queryString}${url.hash}` + return { installerId: af, remainingUri } +}