diff --git a/src/analytics/Events.tsx b/src/analytics/Events.tsx index 75cef75cff8..c197e40c1d5 100644 --- a/src/analytics/Events.tsx +++ b/src/analytics/Events.tsx @@ -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', +} diff --git a/src/analytics/Properties.tsx b/src/analytics/Properties.tsx index 273c3552fef..4969bd38fe4 100644 --- a/src/analytics/Properties.tsx +++ b/src/analytics/Properties.tsx @@ -24,6 +24,7 @@ import { HomeEvents, IdentityEvents, InviteEvents, + JumpstartEvents, KeylessBackupEvents, NavigationEvents, NftEvents, @@ -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 & @@ -1525,6 +1541,7 @@ export type AnalyticsPropertiesList = AppEventsProperties & NftsEventsProperties & BuilderHooksProperties & DappShortcutsProperties & - TransactionDetailsProperties + TransactionDetailsProperties & + WalletJumpstartProperties export type AnalyticsEventType = keyof AnalyticsPropertiesList diff --git a/src/analytics/docs.ts b/src/analytics/docs.ts index 5047d2771ab..67cfee6012f 100644 --- a/src/analytics/docs.ts +++ b/src/analytics/docs.ts @@ -19,6 +19,7 @@ import { HomeEvents, IdentityEvents, InviteEvents, + JumpstartEvents, KeylessBackupEvents, NavigationEvents, NftEvents, @@ -544,6 +545,10 @@ export const eventDocs: Record = { [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. diff --git a/src/app/saga.test.ts b/src/app/saga.test.ts index 2e7643885c5..4c6c6e1444c 100644 --- a/src/app/saga.test.ts +++ b/src/app/saga.test.ts @@ -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' @@ -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') @@ -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, diff --git a/src/app/saga.ts b/src/app/saga.ts index df175460de5..f164ac51977 100644 --- a/src/app/saga.ts +++ b/src/app/saga.ts @@ -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' @@ -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' @@ -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' diff --git a/src/jumpstart/jumpstartLinkHandler.test.ts b/src/jumpstart/jumpstartLinkHandler.test.ts index 5ae61f73ee8..153c5b75d5d 100644 --- a/src/jumpstart/jumpstartLinkHandler.test.ts +++ b/src/jumpstart/jumpstartLinkHandler.test.ts @@ -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' @@ -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' @@ -43,13 +39,18 @@ 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`, @@ -57,13 +58,4 @@ describe('jumpstartLinkHandler', () => { 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() - }) }) diff --git a/src/jumpstart/jumpstartLinkHandler.ts b/src/jumpstart/jumpstartLinkHandler.ts index e697b3b44dc..df0b924ac58 100644 --- a/src/jumpstart/jumpstartLinkHandler.ts +++ b/src/jumpstart/jumpstartLinkHandler.ts @@ -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 { + if (networkId !== networkConfig.defaultNetworkId) { + // TODO: make it multichain (RET-1019) + throw new Error(`Unsupported network id: ${networkId}`) } const kit = newKitFromWeb3(await getWeb3Async()) @@ -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( @@ -40,8 +50,9 @@ export async function executeClaims( userAddress: string, assetType: 'erc20' | 'erc721', privateKey: string -) { +): Promise { let index = 0 + const transactionHashes: Hash[] = [] while (true) { try { const info = @@ -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. @@ -83,7 +98,7 @@ export async function executeClaims( } else { Logger.error(TAG, 'Error claiming jumpstart reward', error) } - return + return transactionHashes } finally { index++ } @@ -107,4 +122,5 @@ export async function claimReward(rewardInfo: RewardInfo) { `Failure response claiming wallet jumpstart reward. ${response.status} ${response.statusText}` ) } + return response.json() } diff --git a/src/jumpstart/saga.test.ts b/src/jumpstart/saga.test.ts new file mode 100644 index 00000000000..271f4181654 --- /dev/null +++ b/src/jumpstart/saga.test.ts @@ -0,0 +1,309 @@ +import { expectSaga } from 'redux-saga-test-plan' +import * as matchers from 'redux-saga-test-plan/matchers' +import { throwError } from 'redux-saga-test-plan/providers' +import { fork } from 'redux-saga/effects' +import { JumpstartEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import { jumpstartLinkHandler } from 'src/jumpstart/jumpstartLinkHandler' +import { + dispatchPendingERC20Transactions, + dispatchPendingERC721Transactions, + dispatchPendingTransactions, + jumpstartClaim, +} from 'src/jumpstart/saga' +import { + jumpstartClaimFailed, + jumpstartClaimStarted, + jumpstartClaimSucceeded, +} from 'src/jumpstart/slice' +import { getDynamicConfigParams } from 'src/statsig' +import { addStandbyTransaction } from 'src/transactions/actions' +import { Network, NetworkId, TokenTransactionTypeV2 } from 'src/transactions/types' +import Logger from 'src/utils/Logger' +import { fetchWithTimeout } from 'src/utils/fetchWithTimeout' +import { publicClient } from 'src/viem' +import { createMockStore } from 'test/utils' +import { + mockAccount, + mockAccount2, + mockAccountInvitePrivKey, + mockCusdAddress, + mockCusdTokenId, + mockNftAllFields, + mockTokenBalances, +} from 'test/values' +import { Hash, TransactionReceipt, parseEventLogs } from 'viem' + +jest.mock('src/statsig') +jest.mock('src/utils/Logger') +jest.mock('src/analytics/ValoraAnalytics') +jest.mock('viem', () => ({ + ...jest.requireActual('viem'), + parseEventLogs: jest.fn(), +})) + +const networkId = NetworkId['celo-alfajores'] +const network = Network.Celo + +const mockPrivateKey = mockAccountInvitePrivKey +const mockWalletAddress = mockAccount +const mockTransactionHashes = ['0xHASH1', '0xHASH2'] as Hash[] +const mockError = new Error('test error') +const mockTransactionReceipt = { + transactionHash: '0xHASH1', + logs: [], +} as unknown as TransactionReceipt + +const mockJumpstartRemoteConfig = { + jumpstartContracts: { + [networkId]: { contractAddress: '0xTEST' }, + }, +} + +const mockErc20Logs = [ + { + eventName: 'ERC20Claimed', + address: mockAccount2, + args: { token: mockCusdAddress, amount: '1000000000000000000' }, + }, +] as unknown as ReturnType + +const mockErc20LogsUnkonwnToken = [ + { + eventName: 'ERC20Claimed', + address: mockAccount2, + args: { token: '0xUNKNOWN', amount: '1000000000000000000' }, + }, +] as unknown as ReturnType + +const mockErc721Logs = [ + { + eventName: 'ERC721Claimed', + address: mockAccount2, + args: { token: mockNftAllFields.contractAddress, tokenId: mockNftAllFields.tokenId }, + }, +] as unknown as ReturnType + +describe('jumpstartClaim', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('handles the happy path', async () => { + jest.mocked(getDynamicConfigParams).mockReturnValue(mockJumpstartRemoteConfig) + + await expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress) + .provide([ + [matchers.call.fn(jumpstartLinkHandler), mockTransactionHashes], + [fork(dispatchPendingTransactions, networkId, mockTransactionHashes), undefined], + ]) + .put(jumpstartClaimStarted()) + .fork(dispatchPendingTransactions, networkId, mockTransactionHashes) + .put(jumpstartClaimSucceeded()) + .run() + + expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claim_succeeded) + }) + + it('handles the jumpstartLinkHandler error', async () => { + jest.mocked(getDynamicConfigParams).mockReturnValue(mockJumpstartRemoteConfig) + + await expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress) + .provide([[matchers.call.fn(jumpstartLinkHandler), throwError(mockError)]]) + .put(jumpstartClaimStarted()) + .put(jumpstartClaimFailed()) + .run() + + expect(Logger.error).toHaveBeenCalledWith( + 'WalletJumpstart', + 'Error handling jumpstart link', + mockError + ) + + expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claim_failed) + }) + + it('does not fail when dispatching pending transactions fails', async () => { + jest.mocked(getDynamicConfigParams).mockReturnValue(mockJumpstartRemoteConfig) + + return expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress) + .provide([ + [matchers.call.fn(jumpstartLinkHandler), mockTransactionHashes], + [matchers.call.fn(dispatchPendingTransactions), throwError(mockError)], + ]) + .put(jumpstartClaimStarted()) + .put(jumpstartClaimSucceeded()) + .run() + }) + + it('fails when dynamic config is empty', async () => { + jest.mocked(getDynamicConfigParams).mockReturnValue({ jumpstartContracts: {} }) + + await expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress) + .provide([[matchers.call.fn(jumpstartLinkHandler), mockTransactionHashes]]) + .put(jumpstartClaimStarted()) + .put(jumpstartClaimFailed()) + .run() + + expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claim_failed) + }) +}) + +describe('dispatchPendingTransactions', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('successfully dispatches pending transactins', async () => { + jest.mocked(parseEventLogs).mockReturnValue(mockErc20Logs) + + return expectSaga(dispatchPendingTransactions, networkId, [mockTransactionHashes[0]]) + .withState( + createMockStore({ + tokens: { + tokenBalances: mockTokenBalances, + }, + }).getState() + ) + .provide([ + [matchers.call.fn(publicClient[network].getTransactionReceipt), mockTransactionReceipt], + ]) + .fork(dispatchPendingERC20Transactions, networkId, [mockTransactionReceipt]) + .fork(dispatchPendingERC721Transactions, networkId, [mockTransactionReceipt]) + .run() + }) + + it('handles the error when getting transaction receipts', async () => { + await expectSaga(dispatchPendingTransactions, networkId, [mockTransactionHashes[0]]) + .provide([ + [matchers.call.fn(publicClient[network].getTransactionReceipt), throwError(mockError)], + ]) + .run() + + expect(Logger.warn).toHaveBeenCalledWith( + 'WalletJumpstart', + 'Error dispatching pending transactions', + mockError + ) + }) +}) + +describe('dispatchPendingERC20Transactions', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('dispatches TokenTransferV3 standby transaction in response to ERC20Claimed logs event', async () => { + const mockTransactionHash = mockTransactionHashes[0] + jest.mocked(parseEventLogs).mockReturnValue(mockErc20Logs) + + await expectSaga(dispatchPendingERC20Transactions, networkId, [mockTransactionReceipt]) + .withState( + createMockStore({ + tokens: { + tokenBalances: mockTokenBalances, + }, + }).getState() + ) + .put( + addStandbyTransaction({ + __typename: 'TokenTransferV3', + type: TokenTransactionTypeV2.Received, + context: { id: mockTransactionHash }, + transactionHash: mockTransactionHash, + networkId, + amount: { value: '1', tokenAddress: mockCusdAddress, tokenId: mockCusdTokenId }, + address: mockAccount2, + metadata: {}, + }) + ) + .run() + + expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claimed_token, { + networkId, + tokenAddress: mockCusdAddress, + value: 1, + }) + }) + + it('does not dispatch TokenTransferV3 standby transaction for an unknown token', async () => { + jest.mocked(parseEventLogs).mockReturnValue(mockErc20LogsUnkonwnToken) + + await expectSaga(dispatchPendingERC20Transactions, networkId, [mockTransactionReceipt]) + .withState( + createMockStore({ + tokens: { + tokenBalances: mockTokenBalances, + }, + }).getState() + ) + .not.put.like({ action: { type: 'ADD_STANDBY_TRANSACTION' } }) + .run() + + expect(Logger.warn).toHaveBeenCalledWith( + 'WalletJumpstart', + 'Claimed unknown tokenId', + 'celo-alfajores:0xUNKNOWN' + ) + }) +}) + +describe('dispatchPendingERC721Transactions', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('dispatches NftTransferV3 standby transaction in response to ERC721Claimed logs event', async () => { + const mockTransactionHash = mockTransactionHashes[0] + + jest.mocked(parseEventLogs).mockReturnValue(mockErc721Logs) + + const tokenUri = 'https://example.com' + const metadata = { ...mockNftAllFields.metadata } + + await expectSaga(dispatchPendingERC721Transactions, networkId, [mockTransactionReceipt]) + .provide([ + [matchers.call.fn(publicClient[network].readContract), tokenUri], + [matchers.call(fetchWithTimeout, tokenUri), { json: () => metadata }], + ]) + .put( + addStandbyTransaction({ + __typename: 'NftTransferV3', + type: TokenTransactionTypeV2.NftReceived, + context: { id: mockTransactionHash }, + transactionHash: mockTransactionHash, + networkId, + nfts: [ + { + tokenId: mockNftAllFields.tokenId, + contractAddress: mockNftAllFields.contractAddress, + tokenUri, + metadata, + media: [{ raw: metadata.image, gateway: metadata.image }], + }, + ], + }) + ) + .run() + + expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claimed_nft, { + networkId, + contractAddress: mockNftAllFields.contractAddress, + tokenId: mockNftAllFields.tokenId, + }) + }) + + it('handles the error when reading tokenUri from ERC721 contract', async () => { + jest.mocked(parseEventLogs).mockReturnValue(mockErc721Logs) + + await expectSaga(dispatchPendingERC721Transactions, networkId, [mockTransactionReceipt]) + .provide([[matchers.call.fn(publicClient[network].readContract), throwError(mockError)]]) + .run() + + expect(Logger.warn).toHaveBeenCalledWith( + 'WalletJumpstart', + 'Error adding pending NFT transaction', + mockError + ) + }) +}) diff --git a/src/jumpstart/saga.ts b/src/jumpstart/saga.ts new file mode 100644 index 00000000000..36c00c505a5 --- /dev/null +++ b/src/jumpstart/saga.ts @@ -0,0 +1,193 @@ +import BigNumber from 'bignumber.js' +import walletJumpstart from 'src/abis/IWalletJumpstart' +import { JumpstartEvents } from 'src/analytics/Events' +import ValoraAnalytics from 'src/analytics/ValoraAnalytics' +import { jumpstartLinkHandler } from 'src/jumpstart/jumpstartLinkHandler' +import { + jumpstartClaimFailed, + jumpstartClaimStarted, + jumpstartClaimSucceeded, +} from 'src/jumpstart/slice' +import { NftMetadata } from 'src/nfts/types' +import { getDynamicConfigParams } from 'src/statsig' +import { DynamicConfigs } from 'src/statsig/constants' +import { StatsigDynamicConfigs } from 'src/statsig/types' +import { tokensByIdSelector } from 'src/tokens/selectors' +import { getTokenId } from 'src/tokens/utils' +import { addStandbyTransaction } from 'src/transactions/actions' +import { NetworkId, TokenTransactionTypeV2 } from 'src/transactions/types' +import Logger from 'src/utils/Logger' +import { fetchWithTimeout } from 'src/utils/fetchWithTimeout' +import { publicClient } from 'src/viem' +import { networkIdToNetwork } from 'src/web3/networkConfig' +import { all, call, fork, put, select } from 'typed-redux-saga' +import { Hash, TransactionReceipt, parseAbi, parseEventLogs } from 'viem' + +const TAG = 'WalletJumpstart' + +export function* jumpstartClaim(privateKey: string, networkId: NetworkId, walletAddress: string) { + try { + yield* put(jumpstartClaimStarted()) + + const contractAddress = getDynamicConfigParams( + DynamicConfigs[StatsigDynamicConfigs.WALLET_JUMPSTART_CONFIG] + ).jumpstartContracts?.[networkId]?.contractAddress + + if (!contractAddress) { + throw new Error(`Contract address for ${networkId} is not provided in dynamic config`) + } + + const transactionHashes = yield* call( + jumpstartLinkHandler, + networkId, + contractAddress, + privateKey, + walletAddress + ) + + yield* fork(dispatchPendingTransactions, networkId, transactionHashes) + + ValoraAnalytics.track(JumpstartEvents.jumpstart_claim_succeeded) + + yield* put(jumpstartClaimSucceeded()) + } catch (error) { + Logger.error(TAG, 'Error handling jumpstart link', error) + ValoraAnalytics.track(JumpstartEvents.jumpstart_claim_failed) + yield* put(jumpstartClaimFailed()) + } +} + +export function* dispatchPendingTransactions(networkId: NetworkId, transactionHashes: Hash[]) { + try { + const network = networkIdToNetwork[networkId] + const transactionReceipts: TransactionReceipt[] = yield* all( + transactionHashes.map((hash) => + call([publicClient[network], 'getTransactionReceipt'], { hash }) + ) + ) + + yield* fork(dispatchPendingERC20Transactions, networkId, transactionReceipts) + yield* fork(dispatchPendingERC721Transactions, networkId, transactionReceipts) + } catch (error) { + Logger.warn(TAG, 'Error dispatching pending transactions', error) + } +} + +export function* dispatchPendingERC20Transactions( + networkId: NetworkId, + transactionReceipts: TransactionReceipt[] +) { + const tokensById = yield* select((state) => tokensByIdSelector(state, [networkId])) + + for (const { transactionHash, logs } of transactionReceipts) { + const parsedLogs = parseEventLogs({ + abi: walletJumpstart.abi, + eventName: ['ERC20Claimed'], + logs, + }) + + for (const { + address, + args: { token: tokenAddress, amount }, + } of parsedLogs) { + const tokenId = getTokenId(networkId, tokenAddress) + + const token = tokensById[tokenId] + if (!token) { + Logger.warn(TAG, 'Claimed unknown tokenId', tokenId) + continue + } + + const value = new BigNumber(amount.toString()).shiftedBy(-token.decimals).toFixed() + + yield* put( + addStandbyTransaction({ + __typename: 'TokenTransferV3', + type: TokenTransactionTypeV2.Received, + context: { + id: transactionHash, + }, + transactionHash, + networkId, + amount: { + value, + tokenAddress, + tokenId, + }, + address, + metadata: {}, + }) + ) + + ValoraAnalytics.track(JumpstartEvents.jumpstart_claimed_token, { + networkId, + tokenAddress, + value: Number(value), + }) + } + } +} + +export function* dispatchPendingERC721Transactions( + networkId: NetworkId, + transactionReceipts: TransactionReceipt[] +) { + for (const { transactionHash, logs } of transactionReceipts) { + const parsedLogs = parseEventLogs({ + abi: walletJumpstart.abi, + eventName: ['ERC721Claimed'], + logs, + }) + + for (const { + args: { token: contractAddress, tokenId }, + } of parsedLogs) { + try { + const network = networkIdToNetwork[networkId] + const tokenUri = (yield* call([publicClient[network], 'readContract'], { + address: contractAddress, + abi: parseAbi(['function tokenURI(uint256 tokenId) returns (string)']), + functionName: 'tokenURI', + args: [tokenId], + })) as string + + const response = yield* call(fetchWithTimeout, tokenUri) + const metadata = (yield* call([response, response.json])) as NftMetadata + + yield* put( + addStandbyTransaction({ + __typename: 'NftTransferV3', + type: TokenTransactionTypeV2.NftReceived, + context: { + id: transactionHash, + }, + transactionHash, + networkId, + nfts: [ + { + tokenId: tokenId.toString(), + contractAddress, + tokenUri, + metadata, + media: [ + { + raw: metadata?.image, + gateway: metadata?.image, + }, + ], + }, + ], + }) + ) + + ValoraAnalytics.track(JumpstartEvents.jumpstart_claimed_nft, { + networkId, + contractAddress, + tokenId: tokenId.toString(), + }) + } catch (error) { + Logger.warn(TAG, 'Error adding pending NFT transaction', error) + } + } + } +} diff --git a/src/jumpstart/selectors.test.ts b/src/jumpstart/selectors.test.ts new file mode 100644 index 00000000000..48c47310224 --- /dev/null +++ b/src/jumpstart/selectors.test.ts @@ -0,0 +1,21 @@ +import { showJumstartError, showJumstartLoading } from 'src/jumpstart/selectors' + +describe('jumpstart selectors', () => { + it('should return the correct value for showJumpstartLoading', () => { + const state: any = { + jumpstart: { + claimStatus: 'loading', + }, + } + expect(showJumstartLoading(state)).toEqual(true) + }) + + it('should return the correct value for showJumpstartError', () => { + const state: any = { + jumpstart: { + claimStatus: 'error', + }, + } + expect(showJumstartError(state)).toEqual(true) + }) +}) diff --git a/src/jumpstart/selectors.ts b/src/jumpstart/selectors.ts new file mode 100644 index 00000000000..51b2b33e889 --- /dev/null +++ b/src/jumpstart/selectors.ts @@ -0,0 +1,9 @@ +import { RootState } from 'src/redux/reducers' + +export const showJumstartLoading = (state: RootState) => { + return state.jumpstart.claimStatus === 'loading' +} + +export const showJumstartError = (state: RootState) => { + return state.jumpstart.claimStatus === 'error' +} diff --git a/src/jumpstart/slice.test.ts b/src/jumpstart/slice.test.ts new file mode 100644 index 00000000000..522a341b0db --- /dev/null +++ b/src/jumpstart/slice.test.ts @@ -0,0 +1,39 @@ +import reducer, { + jumpstartClaimFailed, + jumpstartClaimStarted, + jumpstartClaimSucceeded, + jumpstartErrorDismissed, + jumpstartLoadingDismissed, +} from 'src/jumpstart/slice' + +describe('Wallet Jumpstart', () => { + it('should handle jumpstart claim start', () => { + const updatedState = reducer(undefined, jumpstartClaimStarted()) + + expect(updatedState).toHaveProperty('claimStatus', 'loading') + }) + + it('should handle jumpstart claim success', () => { + const updatedState = reducer(undefined, jumpstartClaimSucceeded()) + + expect(updatedState).toHaveProperty('claimStatus', 'idle') + }) + + it('should handle jumpstart claim failure', () => { + const updatedState = reducer(undefined, jumpstartClaimFailed()) + + expect(updatedState).toHaveProperty('claimStatus', 'error') + }) + + it('should handle jumpstart loading dismiss', () => { + const updatedState = reducer(undefined, jumpstartLoadingDismissed()) + + expect(updatedState).toHaveProperty('claimStatus', 'idle') + }) + + it('should handle jumpstart error dismiss', () => { + const updatedState = reducer(undefined, jumpstartErrorDismissed()) + + expect(updatedState).toHaveProperty('claimStatus', 'idle') + }) +}) diff --git a/src/jumpstart/slice.ts b/src/jumpstart/slice.ts new file mode 100644 index 00000000000..fc4c9f2fd27 --- /dev/null +++ b/src/jumpstart/slice.ts @@ -0,0 +1,50 @@ +import { createSlice } from '@reduxjs/toolkit' + +export interface State { + claimStatus: 'idle' | 'loading' | 'error' +} + +const initialState: State = { + claimStatus: 'idle', +} + +const slice = createSlice({ + name: 'jumpstart', + initialState, + reducers: { + jumpstartClaimStarted: (state) => ({ + ...state, + claimStatus: 'loading', + }), + + jumpstartClaimSucceeded: (state) => ({ + ...state, + claimStatus: 'idle', + }), + + jumpstartClaimFailed: (state) => ({ + ...state, + claimStatus: 'error', + }), + + jumpstartLoadingDismissed: (state) => ({ + ...state, + claimStatus: 'idle', + }), + + jumpstartErrorDismissed: (state) => ({ + ...state, + claimStatus: 'idle', + }), + }, +}) + +export const { + jumpstartClaimStarted, + jumpstartClaimSucceeded, + jumpstartClaimFailed, + jumpstartLoadingDismissed, + jumpstartErrorDismissed, +} = slice.actions + +export default slice.reducer diff --git a/src/nfts/types.ts b/src/nfts/types.ts index 398119a0a0f..e78156e0f7e 100644 --- a/src/nfts/types.ts +++ b/src/nfts/types.ts @@ -10,7 +10,7 @@ interface NftMedia { gateway: string } -interface NftMetadata { +export interface NftMetadata { name: string description: string image: string diff --git a/src/redux/migrations.ts b/src/redux/migrations.ts index 136d4b7a32e..505384866a8 100644 --- a/src/redux/migrations.ts +++ b/src/redux/migrations.ts @@ -1595,4 +1595,10 @@ export const migrations = { }, }), 195: (state: any) => state, + 196: (state: any) => ({ + ...state, + jumpstart: { + claimStatus: 'idle', + }, + }), } diff --git a/src/redux/reducers.ts b/src/redux/reducers.ts index 9b309d22d6b..7446f34424c 100644 --- a/src/redux/reducers.ts +++ b/src/redux/reducers.ts @@ -1,33 +1,34 @@ import { Action, combineReducers } from 'redux' import { PersistState } from 'redux-persist' import { Actions } from 'src/account/actions' -import { reducer as account, State as AccountState } from 'src/account/reducer' -import { reducer as alert, State as AlertState } from 'src/alert/reducer' -import { appReducer as app, State as AppState } from 'src/app/reducers' +import { State as AccountState, reducer as account } from 'src/account/reducer' +import { State as AlertState, reducer as alert } from 'src/alert/reducer' +import { State as AppState, appReducer as app } from 'src/app/reducers' import superchargeReducer, { State as SuperchargeState } from 'src/consumerIncentives/slice' import dappsReducer, { State as DappsState } from 'src/dapps/slice' -import { escrowReducer as escrow, State as EscrowState } from 'src/escrow/reducer' -import { reducer as exchange, State as ExchangeState } from 'src/exchange/reducer' -import { reducer as fees, State as FeesState } from 'src/fees/reducer' +import { State as EscrowState, escrowReducer as escrow } from 'src/escrow/reducer' +import { State as ExchangeState, reducer as exchange } from 'src/exchange/reducer' +import { State as FeesState, reducer as fees } from 'src/fees/reducer' +import { State as FiatExchangesState, reducer as fiatExchanges } from 'src/fiatExchanges/reducer' import fiatConnectReducer, { State as FiatConnectState } from 'src/fiatconnect/slice' -import { reducer as fiatExchanges, State as FiatExchangesState } from 'src/fiatExchanges/reducer' -import { homeReducer as home, State as HomeState } from 'src/home/reducers' +import { State as HomeState, homeReducer as home } from 'src/home/reducers' import i18nReducer, { State as I18nState } from 'src/i18n/slice' -import { reducer as identity, State as IdentityState } from 'src/identity/reducer' -import { reducer as imports, State as ImportState } from 'src/import/reducer' +import { State as IdentityState, reducer as identity } from 'src/identity/reducer' +import { State as ImportState, reducer as imports } from 'src/import/reducer' +import jumpstartReducer, { State as JumpstartState } from 'src/jumpstart/slice' import keylessBackupReducer, { State as KeylessBackupState } from 'src/keylessBackup/slice' -import { reducer as localCurrency, State as LocalCurrencyState } from 'src/localCurrency/reducer' -import { reducer as networkInfo, State as NetworkInfoState } from 'src/networkInfo/reducer' +import { State as LocalCurrencyState, reducer as localCurrency } from 'src/localCurrency/reducer' +import { State as NetworkInfoState, reducer as networkInfo } from 'src/networkInfo/reducer' import nftsReducer, { State as NFTsState } from 'src/nfts/slice' import positionsReducer, { State as PositionsState } from 'src/positions/slice' -import { recipientsReducer as recipients, State as RecipientsState } from 'src/recipients/reducer' -import { sendReducer as send, State as SendState } from 'src/send/reducers' +import priceHistoryReducer, { State as priceHistoryState } from 'src/priceHistory/slice' +import { State as RecipientsState, recipientsReducer as recipients } from 'src/recipients/reducer' +import { State as SendState, sendReducer as send } from 'src/send/reducers' import swapReducer, { State as SwapState } from 'src/swap/slice' import tokenReducer, { State as TokensState } from 'src/tokens/slice' -import { reducer as transactions, State as TransactionsState } from 'src/transactions/reducer' -import { reducer as walletConnect, State as WalletConnectState } from 'src/walletConnect/reducer' -import { reducer as web3, State as Web3State } from 'src/web3/reducer' -import priceHistoryReducer, { State as priceHistoryState } from 'src/priceHistory/slice' +import { State as TransactionsState, reducer as transactions } from 'src/transactions/reducer' +import { State as WalletConnectState, reducer as walletConnect } from 'src/walletConnect/reducer' +import { State as Web3State, reducer as web3 } from 'src/web3/reducer' const appReducer = combineReducers({ app, @@ -57,6 +58,7 @@ const appReducer = combineReducers({ keylessBackup: keylessBackupReducer, nfts: nftsReducer, priceHistory: priceHistoryReducer, + jumpstart: jumpstartReducer, }) as (state: RootState | undefined, action: Action) => RootState const rootReducer = (state: RootState | undefined, action: Action): RootState => { @@ -107,6 +109,7 @@ export interface RootState { keylessBackup: KeylessBackupState nfts: NFTsState priceHistory: priceHistoryState + jumpstart: JumpstartState } export interface PersistedRootState { diff --git a/src/redux/store.test.ts b/src/redux/store.test.ts index e84fc51a561..4b5a8732c6d 100644 --- a/src/redux/store.test.ts +++ b/src/redux/store.test.ts @@ -98,7 +98,7 @@ describe('store state', () => { { "_persist": { "rehydrated": true, - "version": 195, + "version": 196, }, "account": { "acceptedTerms": false, @@ -260,6 +260,9 @@ describe('store state', () => { "imports": { "isImportingWallet": false, }, + "jumpstart": { + "claimStatus": "idle", + }, "keylessBackup": { "backupStatus": "NotStarted", "deleteBackupStatus": "NotStarted", diff --git a/src/redux/store.ts b/src/redux/store.ts index 3cb780fd104..2c2f6cebbab 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -23,10 +23,10 @@ const persistConfig: PersistConfig = { key: 'root', // default is -1, increment as we make migrations // See https://github.com/valora-inc/wallet/tree/main/WALLET.md#redux-state-migration - version: 195, + version: 196, keyPrefix: `reduxStore-`, // the redux-persist default is `persist:` which doesn't work with some file systems. storage: FSStorage(), - blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup'], + blacklist: ['networkInfo', 'alert', 'imports', 'keylessBackup', 'jumpstart'], stateReconciler: autoMergeLevel2, migrate: async (...args) => { const migrate = createMigrate(migrations) @@ -141,6 +141,7 @@ export const setupStore = (initialState = {}, config = persistConfig) => { 'keylessBackup', 'nfts', 'swap', + 'jumpstart', // 'exchange', // 'tokens', // 'priceHistory', diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index dfa7e4807b5..fd1d0da0441 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -4,6 +4,7 @@ import { Fee, NetworkId, PendingStandbyApproval, + PendingStandbyNFTTransfer, PendingStandbySwap, PendingStandbyTransfer, TokenTransaction, @@ -25,6 +26,7 @@ type BaseStandbyTransaction = | Omit | Omit | Omit + | Omit export interface AddStandbyTransactionAction { type: Actions.ADD_STANDBY_TRANSACTION diff --git a/src/transactions/types.ts b/src/transactions/types.ts index 4a844ba81c6..68dedae1c65 100644 --- a/src/transactions/types.ts +++ b/src/transactions/types.ts @@ -41,10 +41,18 @@ export type PendingStandbyApproval = { feeCurrencyId?: string } & Omit +export type PendingStandbyNFTTransfer = { + transactionHash?: string + context: TransactionContext + status: TransactionStatus.Pending + feeCurrencyId?: string +} & Omit + export type ConfirmedStandbyTransaction = ( | Omit | Omit | Omit + | Omit ) & { status: TransactionStatus.Complete | TransactionStatus.Failed context: TransactionContext @@ -55,6 +63,7 @@ export type StandbyTransaction = | PendingStandbySwap | PendingStandbyTransfer | PendingStandbyApproval + | PendingStandbyNFTTransfer | ConfirmedStandbyTransaction // Context used for logging the transaction execution flow. diff --git a/test/RootStateSchema.json b/test/RootStateSchema.json index 2d6c196986f..71b5595b536 100644 --- a/test/RootStateSchema.json +++ b/test/RootStateSchema.json @@ -2149,6 +2149,52 @@ ], "type": "object" }, + "PendingStandbyNFTTransfer": { + "additionalProperties": false, + "properties": { + "__typename": { + "const": "NftTransferV3", + "type": "string" + }, + "context": { + "$ref": "#/definitions/TransactionContext" + }, + "feeCurrencyId": { + "type": "string" + }, + "networkId": { + "$ref": "#/definitions/NetworkId" + }, + "nfts": { + "items": { + "$ref": "#/definitions/Nft" + }, + "type": "array" + }, + "status": { + "const": "Pending", + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "transactionHash": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/TokenTransactionTypeV2" + } + }, + "required": [ + "__typename", + "context", + "networkId", + "status", + "timestamp", + "type" + ], + "type": "object" + }, "PendingStandbySwap": { "additionalProperties": false, "properties": { @@ -2815,6 +2861,9 @@ { "$ref": "#/definitions/PendingStandbyApproval" }, + { + "$ref": "#/definitions/PendingStandbyNFTTransfer" + }, { "additionalProperties": false, "properties": { @@ -3013,6 +3062,66 @@ "type" ], "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "__typename": { + "const": "NftTransferV3", + "type": "string" + }, + "block": { + "type": "string" + }, + "context": { + "$ref": "#/definitions/TransactionContext" + }, + "feeCurrencyId": { + "type": "string" + }, + "fees": { + "items": { + "$ref": "#/definitions/Fee" + }, + "type": "array" + }, + "networkId": { + "$ref": "#/definitions/NetworkId" + }, + "nfts": { + "items": { + "$ref": "#/definitions/Nft" + }, + "type": "array" + }, + "status": { + "enum": [ + "Complete", + "Failed" + ], + "type": "string" + }, + "timestamp": { + "type": "number" + }, + "transactionHash": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/TokenTransactionTypeV2" + } + }, + "required": [ + "__typename", + "block", + "context", + "networkId", + "status", + "timestamp", + "transactionHash", + "type" + ], + "type": "object" } ] }, @@ -4056,6 +4165,23 @@ }, "type": "object" }, + "State_27": { + "additionalProperties": false, + "properties": { + "claimStatus": { + "enum": [ + "error", + "idle", + "loading" + ], + "type": "string" + } + }, + "required": [ + "claimStatus" + ], + "type": "object" + }, "State_3": { "anyOf": [ { @@ -5776,6 +5902,9 @@ "imports": { "$ref": "#/definitions/State_15" }, + "jumpstart": { + "$ref": "#/definitions/State_27" + }, "keylessBackup": { "$ref": "#/definitions/State_24" }, @@ -5857,6 +5986,7 @@ "i18n", "identity", "imports", + "jumpstart", "keylessBackup", "localCurrency", "networkInfo", diff --git a/test/schemas.ts b/test/schemas.ts index 30289b089b4..ff2e1651429 100644 --- a/test/schemas.ts +++ b/test/schemas.ts @@ -3110,6 +3110,17 @@ export const v195Schema = { }, } +export const v196Schema = { + ...v195Schema, + _persist: { + ...v195Schema._persist, + version: 196, + }, + jumpstart: { + claimStatus: 'idle', + }, +} + export function getLatestSchema(): Partial { - return v195Schema as Partial + return v196Schema as Partial }