Skip to content

Commit

Permalink
feat(jumpstart): handle jumpstart state (#4963)
Browse files Browse the repository at this point in the history
### Description

1. Introducing new slice to the redux state for jumpstart link handling:
```
jumpstart: {
  showLoading: boolean
  showError: boolean
}
```
This state is expected to be used for rendering UI components displaying
the jumpstart link handling state to the user. The actual components
will follow in the subsequent PR once the design discussions converge.

2. After successful jumpstart claim two types of pending transactions
are dispatched (getting tx data trough blockchain RPC calls):
* Payment received (for ERC-20 claim)
* NFT received (for ERC-721 claim)

Those transactions are expected to be replaced with blockchain-api data
once it's fetched.

3. Added analytics events for jumpstart handler lifecycle:
* claim succeeded
* claim failed
* ERC20 token claimed
* ERC721 nft claimed

### Test plan

* Updated unit tests

### Related issues

- Related to RET-1004

### Backwards compatibility

Y

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [x] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
bakoushin committed Mar 1, 2024
1 parent b2b7890 commit 7672934
Show file tree
Hide file tree
Showing 22 changed files with 907 additions and 64 deletions.
7 changes: 7 additions & 0 deletions src/analytics/Events.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,3 +638,10 @@ export enum TransactionDetailsEvents {
transaction_details_tap_retry = 'transaction_details_tap_retry',
transaction_details_tap_block_explorer = 'transaction_details_tap_block_explorer',
}

export enum JumpstartEvents {
jumpstart_claim_succeeded = 'jumpstart_claim_succeeded',
jumpstart_claim_failed = 'jumpstart_claim_failed',
jumpstart_claimed_token = 'jumpstart_claimed_token',
jumpstart_claimed_nft = 'jumpstart_claimed_nft',
}
19 changes: 18 additions & 1 deletion src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
HomeEvents,
IdentityEvents,
InviteEvents,
JumpstartEvents,
KeylessBackupEvents,
NavigationEvents,
NftEvents,
Expand Down Expand Up @@ -1492,6 +1493,21 @@ interface TransactionDetailsProperties {
}
}

interface WalletJumpstartProperties {
[JumpstartEvents.jumpstart_claim_succeeded]: undefined
[JumpstartEvents.jumpstart_claim_failed]: undefined
[JumpstartEvents.jumpstart_claimed_token]: {
networkId: NetworkId
tokenAddress: string
value: number
}
[JumpstartEvents.jumpstart_claimed_nft]: {
networkId: NetworkId
contractAddress: string
tokenId: string
}
}

export type AnalyticsPropertiesList = AppEventsProperties &
HomeEventsProperties &
SettingsEventsProperties &
Expand Down Expand Up @@ -1525,6 +1541,7 @@ export type AnalyticsPropertiesList = AppEventsProperties &
NftsEventsProperties &
BuilderHooksProperties &
DappShortcutsProperties &
TransactionDetailsProperties
TransactionDetailsProperties &
WalletJumpstartProperties

export type AnalyticsEventType = keyof AnalyticsPropertiesList
5 changes: 5 additions & 0 deletions src/analytics/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
HomeEvents,
IdentityEvents,
InviteEvents,
JumpstartEvents,
KeylessBackupEvents,
NavigationEvents,
NftEvents,
Expand Down Expand Up @@ -544,6 +545,10 @@ export const eventDocs: Record<AnalyticsEventType, string> = {
[TransactionDetailsEvents.transaction_details_tap_check_status]: `When a user press 'Check status' on transaction details page`,
[TransactionDetailsEvents.transaction_details_tap_retry]: `When a user press 'Retry' on transaction details page`,
[TransactionDetailsEvents.transaction_details_tap_block_explorer]: `When a user press 'View on block explorer' on transaction details page`,
[JumpstartEvents.jumpstart_claim_succeeded]: `When claiming from Wallet Jumpstart succeeded`,
[JumpstartEvents.jumpstart_claim_failed]: `When claiming from Wallet Jumpstart failed`,
[JumpstartEvents.jumpstart_claimed_token]: `When user successfully claimed an ERC20 token trough Wallet Jumpstart`,
[JumpstartEvents.jumpstart_claimed_nft]: `When user successfully claimed an NFT trough Wallet Jumpstart`,

// Legacy event docs
// The below events had docs, but are no longer produced by the latest app version.
Expand Down
26 changes: 22 additions & 4 deletions src/app/saga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ import { handleEnableHooksPreviewDeepLink } from 'src/positions/saga'
import { allowHooksPreviewSelector } from 'src/positions/selectors'
import { handlePaymentDeeplink } from 'src/send/utils'
import { initializeSentry } from 'src/sentry/Sentry'
import { getFeatureGate, patchUpdateStatsigUser } from 'src/statsig'
import { getDynamicConfigParams, getFeatureGate, patchUpdateStatsigUser } from 'src/statsig'
import { NetworkId } from 'src/transactions/types'
import { navigateToURI } from 'src/utils/linking'
import Logger from 'src/utils/Logger'
import { ONE_DAY_IN_MILLIS } from 'src/utils/time'
Expand All @@ -70,7 +71,7 @@ import {
walletAddressSelector,
} from 'src/web3/selectors'
import { createMockStore } from 'test/utils'
import { mockAccount } from 'test/values'
import { mockAccount, mockTokenBalances } from 'test/values'

jest.mock('src/dappkit/dappkit')
jest.mock('src/analytics/ValoraAnalytics')
Expand Down Expand Up @@ -265,12 +266,29 @@ describe('handleDeepLink', () => {
})

it('Handles jumpstart links', async () => {
const deepLink = 'celo://wallet/jumpstart/0xPrivateKey'
const deepLink = 'celo://wallet/jumpstart/0xPrivateKey/celo-alfajores'
jest.mocked(getDynamicConfigParams).mockReturnValue({
jumpstartContracts: {
[NetworkId['celo-alfajores']]: { contractAddress: '0xTEST' },
},
})
await expectSaga(handleDeepLink, openDeepLink(deepLink))
.withState(
createMockStore({
tokens: {
tokenBalances: mockTokenBalances,
},
}).getState()
)
.provide([[select(walletAddressSelector), '0xwallet']])
.run()

expect(jumpstartLinkHandler).toHaveBeenCalledWith('0xPrivateKey', '0xwallet')
expect(jumpstartLinkHandler).toHaveBeenCalledWith(
'celo-alfajores',
'0xTEST',
'0xPrivateKey',
'0xwallet'
)
expect(ValoraAnalytics.track).toHaveBeenCalledWith(AppEvents.handle_deeplink, {
pathStartsWith: 'jumpstart',
fullPath: null,
Expand Down
8 changes: 5 additions & 3 deletions src/app/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {
} from 'src/i18n/selectors'
import { E164NumberToSaltType } from 'src/identity/reducer'
import { e164NumberToSaltSelector } from 'src/identity/selectors'
import { jumpstartLinkHandler } from 'src/jumpstart/jumpstartLinkHandler'
import { jumpstartClaim } from 'src/jumpstart/saga'
import { navigate, navigateHome } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { StackParamList } from 'src/navigator/types'
Expand All @@ -69,6 +69,7 @@ import { SentryTransaction } from 'src/sentry/SentryTransactions'
import { getFeatureGate, patchUpdateStatsigUser, setupOverridesFromLaunchArgs } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import { swapSuccess } from 'src/swap/slice'
import { NetworkId } from 'src/transactions/types'
import { ensureError } from 'src/utils/ensureError'
import { isDeepLink, navigateToURI } from 'src/utils/linking'
import Logger from 'src/utils/Logger'
Expand Down Expand Up @@ -357,9 +358,10 @@ export function* handleDeepLink(action: OpenDeepLink) {
ValoraAnalytics.track(InviteEvents.opened_via_invite_url, {
inviterAddress,
})
} else if (pathParts.length === 3 && pathParts[1] === 'jumpstart') {
} else if (pathParts.length === 4 && pathParts[1] === 'jumpstart') {
const privateKey = pathParts[2]
yield* call(jumpstartLinkHandler, privateKey, walletAddress)
const networkId = pathParts[3] as NetworkId
yield* call(jumpstartClaim, privateKey, networkId, walletAddress)
} else if (
(yield* select(allowHooksPreviewSelector)) &&
rawParams.pathname === '/hooks/enablePreview'
Expand Down
26 changes: 9 additions & 17 deletions src/jumpstart/jumpstartLinkHandler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { getDynamicConfigParams } from 'src/statsig'
import Logger from 'src/utils/Logger'
import { fetchWithTimeout } from 'src/utils/fetchWithTimeout'
import networkConfig from 'src/web3/networkConfig'
import { mockAccount, mockAccount2 } from 'test/values'
Expand Down Expand Up @@ -30,8 +28,6 @@ jest.mock('src/web3/utils', () => ({
})),
}))
jest.mock('src/utils/fetchWithTimeout')
jest.mock('src/statsig')
jest.mock('src/utils/Logger')

describe('jumpstartLinkHandler', () => {
const privateKey = '0x1234567890abcdef'
Expand All @@ -43,27 +39,23 @@ describe('jumpstartLinkHandler', () => {
it('calls executeClaims with correct parameters', async () => {
;(fetchWithTimeout as jest.Mock).mockImplementation(() => ({
ok: true,
json: async () => ({ transactionHash: '0xHASH' }),
}))
jest.mocked(getDynamicConfigParams).mockReturnValue({
jumpstartContracts: { [networkConfig.defaultNetworkId]: { contractAddress: '0xTEST' } },
})
const contractAddress = '0xTEST'

await jumpstartLinkHandler(privateKey, mockAccount2)
const result = await jumpstartLinkHandler(
networkConfig.defaultNetworkId,
contractAddress,
privateKey,
mockAccount2
)

expect(result).toEqual(['0xHASH'])
expect(fetchWithTimeout).toHaveBeenCalledTimes(1)
expect(fetchWithTimeout).toHaveBeenCalledWith(
`https://api.alfajores.valora.xyz/walletJumpstart?index=1&beneficiary=${mockAccount}&signature=0xweb3-signature&sendTo=${mockAccount2}&assetType=erc20`,
expect.any(Object),
expect.any(Number)
)
})

it('fails when contract address is not provided in dynamic config', async () => {
jest.mocked(getDynamicConfigParams).mockReturnValue({})

await jumpstartLinkHandler(privateKey, mockAccount2)

expect(fetchWithTimeout).not.toHaveBeenCalled()
expect(Logger.error).toHaveBeenCalled()
})
})
48 changes: 32 additions & 16 deletions src/jumpstart/jumpstartLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { Contract } from '@celo/connect'
import { ContractKit, newKitFromWeb3 } from '@celo/contractkit'
import walletJumpstart from 'src/abis/IWalletJumpstart'
import { getDynamicConfigParams } from 'src/statsig'
import { DynamicConfigs } from 'src/statsig/constants'
import { StatsigDynamicConfigs } from 'src/statsig/types'
import { NetworkId } from 'src/transactions/types'
import Logger from 'src/utils/Logger'
import { fetchWithTimeout } from 'src/utils/fetchWithTimeout'
import { getWeb3Async } from 'src/web3/contracts'
import networkConfig from 'src/web3/networkConfig'
import { getContract } from 'src/web3/utils'
import { Hash } from 'viem'

const TAG = 'WalletJumpstart'

export async function jumpstartLinkHandler(privateKey: string, userAddress: string) {
const contractAddress = getDynamicConfigParams(
DynamicConfigs[StatsigDynamicConfigs.WALLET_JUMPSTART_CONFIG]
).jumpstartContracts?.[networkConfig.defaultNetworkId]?.contractAddress

if (!contractAddress) {
Logger.error(TAG, 'Contract address is not provided in dynamic config')
return
export async function jumpstartLinkHandler(
networkId: NetworkId,
contractAddress: string,
privateKey: string,
userAddress: string
): Promise<Hash[]> {
if (networkId !== networkConfig.defaultNetworkId) {
// TODO: make it multichain (RET-1019)
throw new Error(`Unsupported network id: ${networkId}`)
}

const kit = newKitFromWeb3(await getWeb3Async())
Expand All @@ -29,8 +29,18 @@ export async function jumpstartLinkHandler(privateKey: string, userAddress: stri

const jumpstart: Contract = await getContract(walletJumpstart.abi, contractAddress)

await executeClaims(kit, jumpstart, publicKey, userAddress, 'erc20', privateKey)
await executeClaims(kit, jumpstart, publicKey, userAddress, 'erc721', privateKey)
const transactionHashes = (
await Promise.all([
executeClaims(kit, jumpstart, publicKey, userAddress, 'erc20', privateKey),
executeClaims(kit, jumpstart, publicKey, userAddress, 'erc721', privateKey),
])
).flat()

if (transactionHashes.length === 0) {
throw new Error(`Failed to claim any jumpstart reward for ${networkId}`)
}

return transactionHashes
}

export async function executeClaims(
Expand All @@ -40,8 +50,9 @@ export async function executeClaims(
userAddress: string,
assetType: 'erc20' | 'erc721',
privateKey: string
) {
): Promise<Hash[]> {
let index = 0
const transactionHashes: Hash[] = []
while (true) {
try {
const info =
Expand All @@ -64,13 +75,17 @@ export async function executeClaims(

const { signature } = await kit.web3.eth.accounts.sign(messageHash, privateKey)

await claimReward({
const { transactionHash } = await claimReward({
index: index.toString(),
beneficiary,
signature,
sendTo: userAddress,
assetType,
})

if (transactionHash) {
transactionHashes.push(transactionHash)
}
} catch (error: any) {
if (error.message === 'execution reverted') {
// This happens when using an index that doesn't exist.
Expand All @@ -83,7 +98,7 @@ export async function executeClaims(
} else {
Logger.error(TAG, 'Error claiming jumpstart reward', error)
}
return
return transactionHashes
} finally {
index++
}
Expand All @@ -107,4 +122,5 @@ export async function claimReward(rewardInfo: RewardInfo) {
`Failure response claiming wallet jumpstart reward. ${response.status} ${response.statusText}`
)
}
return response.json()
}
Loading

0 comments on commit 7672934

Please sign in to comment.