Skip to content

Commit

Permalink
feat(jumpstart): add specific error messaging when link is already cl…
Browse files Browse the repository at this point in the history
…aimed (#5427)

### Description

As the title

### Test plan


https://github.com/valora-inc/wallet/assets/20150449/b701d6b9-758e-4742-99f8-02da11641ba9


### Related issues

- Fixes RET-1075

### Backwards compatibility

Y

### Network scalability

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

- [ ] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
kathaypacific committed May 21, 2024
1 parent 86c921d commit 8dd5080
Show file tree
Hide file tree
Showing 11 changed files with 89 additions and 83 deletions.
7 changes: 4 additions & 3 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2240,12 +2240,13 @@
},
"jumpstartStatus": {
"loading": {
"title": "Your funds are on their way",
"description": "The funds that your friend gave you are being sent over. Please wait... "
"title": "Checking on your funds",
"description": "Please wait while we check your Live Link. Claimable funds will arrive shortly."
},
"error": {
"title": "Unable to claim funds",
"description": "Try your live link again to attempt to receive funds, or ask your friend to try sending a new link",
"description": "This link was not able to be claimed for an unknown reason",
"alreadyClaimedDescription": "The link has already been claimed. Please ask your friend to send a new link",
"contactSupport": "Contact Support",
"dismiss": "Dismiss"
}
Expand Down
19 changes: 12 additions & 7 deletions src/jumpstart/JumpstartClaimStatusToasts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ValoraAnalytics from 'src/analytics/ValoraAnalytics'
import { NotificationVariant } from 'src/components/InLineNotification'
import Toast from 'src/components/Toast'
import GreenLoadingSpinner from 'src/icons/GreenLoadingSpinner'
import { showJumstartClaimError, showJumstartClaimLoading } from 'src/jumpstart/selectors'
import { jumpstartClaimStatusSelector } from 'src/jumpstart/selectors'
import { jumpstartClaimErrorDismissed, jumpstartClaimLoadingDismissed } from 'src/jumpstart/slice'
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
Expand All @@ -15,8 +15,7 @@ import { Spacing } from 'src/styles/styles'
export default function JumpstartClaimStatusToasts() {
const { t } = useTranslation()
const dispatch = useDispatch()
const showLoading = useSelector(showJumstartClaimLoading)
const showError = useSelector(showJumstartClaimError)
const claimStatus = useSelector(jumpstartClaimStatusSelector)

const handleLoadingDismiss = () => {
ValoraAnalytics.track(JumpstartEvents.jumpstart_claim_loading_dismissed)
Expand All @@ -38,19 +37,25 @@ export default function JumpstartClaimStatusToasts() {
<>
<Toast
swipeable
showToast={showLoading}
showToast={claimStatus === 'loading'}
variant={NotificationVariant.Info}
title={t('jumpstartStatus.loading.title')}
description={t('jumpstartStatus.loading.description')}
customIcon={<GreenLoadingSpinner height={Spacing.Thick24} />}
onDismiss={handleLoadingDismiss}
/>
<Toast
showToast={showError}
showToast={claimStatus === 'error' || claimStatus === 'errorAlreadyClaimed'}
variant={NotificationVariant.Error}
title={t('jumpstartStatus.error.title')}
description={t('jumpstartStatus.error.description')}
ctaLabel={t('jumpstartStatus.error.contactSupport')}
description={
claimStatus === 'errorAlreadyClaimed'
? t('jumpstartStatus.error.alreadyClaimedDescription')
: t('jumpstartStatus.error.description')
}
ctaLabel={
claimStatus === 'errorAlreadyClaimed' ? null : t('jumpstartStatus.error.contactSupport')
}
onPressCta={handleContactSupport}
ctaLabel2={t('jumpstartStatus.error.dismiss')}
onPressCta2={handleErrorDismiss}
Expand Down
38 changes: 23 additions & 15 deletions src/jumpstart/jumpstartLinkHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,13 @@ import networkConfig from 'src/web3/networkConfig'
import { mockAccount, mockAccount2 } from 'test/values'
import { jumpstartLinkHandler } from './jumpstartLinkHandler'

jest.mock('src/web3/providers')
const mockErc20ClaimsCall = jest.fn()
jest.mock('src/web3/utils', () => ({
...(jest.requireActual('src/web3/utils') as any),
getContract: jest.fn().mockImplementation(() => ({
methods: {
erc20Claims: (_: string, index: number) => ({
call: () => {
if (index === 0) {
return { claimed: true }
} else if (index === 1) {
return { claimed: false }
} else {
throw new Error('execution reverted')
}
},
call: () => mockErc20ClaimsCall(),
}),
erc721Claims: () => ({
call: () => {
Expand All @@ -27,6 +19,7 @@ jest.mock('src/web3/utils', () => ({
},
})),
}))
jest.mock('src/web3/providers')
jest.mock('src/utils/fetchWithTimeout')

describe('jumpstartLinkHandler', () => {
Expand All @@ -36,11 +29,14 @@ describe('jumpstartLinkHandler', () => {
jest.clearAllMocks()
})

it('calls executeClaims with correct parameters', async () => {
;(fetchWithTimeout as jest.Mock).mockImplementation(() => ({
ok: true,
json: async () => ({ result: { transactionHash: '0xHASH' } }),
}))
it('claims any unclaimed funds associated with the private key', async () => {
mockErc20ClaimsCall.mockResolvedValueOnce({ claimed: true })
mockErc20ClaimsCall.mockResolvedValueOnce({ claimed: false })
mockErc20ClaimsCall.mockRejectedValue(new Error('execution reverted'))

jest
.mocked(fetchWithTimeout)
.mockResolvedValue(new Response(JSON.stringify({ result: { transactionHash: '0xHASH' } })))
const contractAddress = '0xTEST'

const result = await jumpstartLinkHandler(
Expand All @@ -58,4 +54,16 @@ describe('jumpstartLinkHandler', () => {
expect.any(Number)
)
})

it('throws an error if all funds were already claimed', async () => {
mockErc20ClaimsCall.mockResolvedValueOnce({ claimed: true })
mockErc20ClaimsCall.mockResolvedValueOnce({ claimed: true })
mockErc20ClaimsCall.mockRejectedValue(new Error('execution reverted'))

await expect(
jumpstartLinkHandler(networkConfig.defaultNetworkId, '0xTEST', privateKey, mockAccount2)
).rejects.toThrow('Already claimed all jumpstart rewards for celo-alfajores')

expect(fetchWithTimeout).not.toHaveBeenCalled()
})
})
23 changes: 15 additions & 8 deletions src/jumpstart/jumpstartLinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,18 @@ export async function jumpstartLinkHandler(

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

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

const transactionHashes = results.flatMap((result) => result.transactionHashes)
const hasClaimedAssets = results.some((result) => result.hasClaimedAssets)

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

Expand All @@ -51,16 +55,19 @@ export async function executeClaims(
assetType: 'erc20' | 'erc721',
privateKey: string,
networkId: NetworkId
): Promise<Hash[]> {
): Promise<{ transactionHashes: Hash[]; hasClaimedAssets: boolean }> {
let index = 0
const transactionHashes: Hash[] = []
let hasClaimedAssets = false
while (true) {
try {
const info =
assetType === 'erc20'
? await jumpstart.methods.erc20Claims(beneficiary, index).call()
: await jumpstart.methods.erc721Claims(beneficiary, index).call()

if (info.claimed) {
hasClaimedAssets = true
continue
}

Expand Down Expand Up @@ -102,7 +109,7 @@ export async function executeClaims(
} else {
Logger.error(TAG, 'Error claiming jumpstart reward', error)
}
return transactionHashes
return { transactionHashes, hasClaimedAssets }
} finally {
index++
}
Expand Down
37 changes: 24 additions & 13 deletions src/jumpstart/saga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,12 @@ const mockErc721Logs = [
] as unknown as ReturnType<typeof parseEventLogs>

describe('jumpstartClaim', () => {
afterEach(() => {
beforeEach(() => {
jest.clearAllMocks()
})

it('handles the happy path', async () => {
jest.mocked(getDynamicConfigParams).mockReturnValue(mockJumpstartRemoteConfig)
})

it('handles a successful claim', async () => {
await expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress)
.provide([
[matchers.call.fn(jumpstartLinkHandler), mockTransactionHashes],
Expand All @@ -130,12 +129,10 @@ describe('jumpstartClaim', () => {
})

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())
.put(jumpstartClaimFailed({ isAlreadyClaimed: false }))
.run()

expect(Logger.error).toHaveBeenCalledWith(
Expand All @@ -147,9 +144,23 @@ describe('jumpstartClaim', () => {
expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claim_failed)
})

it('does not fail when dispatching pending transactions fails', async () => {
jest.mocked(getDynamicConfigParams).mockReturnValue(mockJumpstartRemoteConfig)
it('handles the already claimed error', async () => {
const alreadyClaimedError = new Error('Already claimed')
await expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress)
.provide([[matchers.call.fn(jumpstartLinkHandler), throwError(alreadyClaimedError)]])
.put(jumpstartClaimStarted())
.put(jumpstartClaimFailed({ isAlreadyClaimed: true }))
.run()

expect(Logger.error).toHaveBeenCalledWith(
'WalletJumpstart/saga',
'Error handling jumpstart link',
alreadyClaimedError
)
expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claim_failed)
})

it('does not fail when dispatching pending transactions fails', async () => {
return expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress)
.provide([
[matchers.call.fn(jumpstartLinkHandler), mockTransactionHashes],
Expand All @@ -166,15 +177,15 @@ describe('jumpstartClaim', () => {
await expectSaga(jumpstartClaim, mockPrivateKey, networkId, mockWalletAddress)
.provide([[matchers.call.fn(jumpstartLinkHandler), mockTransactionHashes]])
.put(jumpstartClaimStarted())
.put(jumpstartClaimFailed())
.put(jumpstartClaimFailed({ isAlreadyClaimed: false }))
.run()

expect(ValoraAnalytics.track).toHaveBeenCalledWith(JumpstartEvents.jumpstart_claim_failed)
})
})

describe('dispatchPendingTransactions', () => {
afterEach(() => {
beforeEach(() => {
jest.clearAllMocks()
})

Expand Down Expand Up @@ -213,7 +224,7 @@ describe('dispatchPendingTransactions', () => {
})

describe('dispatchPendingERC20Transactions', () => {
afterEach(() => {
beforeEach(() => {
jest.clearAllMocks()
})

Expand Down Expand Up @@ -273,7 +284,7 @@ describe('dispatchPendingERC20Transactions', () => {
})

describe('dispatchPendingERC721Transactions', () => {
afterEach(() => {
beforeEach(() => {
jest.clearAllMocks()
})

Expand Down
6 changes: 4 additions & 2 deletions src/jumpstart/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,12 @@ export function* jumpstartClaim(privateKey: string, networkId: NetworkId, wallet
ValoraAnalytics.track(JumpstartEvents.jumpstart_claim_succeeded)

yield* put(jumpstartClaimSucceeded())
} catch (error) {
} catch (error: any) {
Logger.error(TAG, 'Error handling jumpstart link', error)
ValoraAnalytics.track(JumpstartEvents.jumpstart_claim_failed)
yield* put(jumpstartClaimFailed())
yield* put(
jumpstartClaimFailed({ isAlreadyClaimed: error?.message?.includes('Already claimed') })
)
}
}

Expand Down
21 changes: 0 additions & 21 deletions src/jumpstart/selectors.test.ts

This file was deleted.

8 changes: 2 additions & 6 deletions src/jumpstart/selectors.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { RootState } from 'src/redux/reducers'

export const showJumstartClaimLoading = (state: RootState) => {
return state.jumpstart.claimStatus === 'loading'
}

export const showJumstartClaimError = (state: RootState) => {
return state.jumpstart.claimStatus === 'error'
export const jumpstartClaimStatusSelector = (state: RootState) => {
return state.jumpstart.claimStatus
}

export const jumpstartSendStatusSelector = (state: RootState) => {
Expand Down
2 changes: 1 addition & 1 deletion src/jumpstart/slice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('Wallet Jumpstart', () => {
})

it('should handle jumpstart claim failure', () => {
const updatedState = reducer(undefined, jumpstartClaimFailed())
const updatedState = reducer(undefined, jumpstartClaimFailed({ isAlreadyClaimed: false }))

expect(updatedState).toHaveProperty('claimStatus', 'error')
})
Expand Down
10 changes: 3 additions & 7 deletions src/jumpstart/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface JumpstartTransactionStartedAction {
sendAmount: string
}
interface State {
claimStatus: 'idle' | 'loading' | 'error'
claimStatus: 'idle' | 'loading' | 'error' | 'errorAlreadyClaimed'
depositStatus: 'idle' | 'loading' | 'error' | 'success'
reclaimStatus: 'idle' | 'loading' | 'error' | 'success'
}
Expand All @@ -34,22 +34,18 @@ const slice = createSlice({
...state,
claimStatus: 'loading',
}),

jumpstartClaimSucceeded: (state) => ({
...state,
claimStatus: 'idle',
}),

jumpstartClaimFailed: (state) => ({
jumpstartClaimFailed: (state, action: PayloadAction<{ isAlreadyClaimed: boolean }>) => ({
...state,
claimStatus: 'error',
claimStatus: action.payload.isAlreadyClaimed ? 'errorAlreadyClaimed' : 'error',
}),

jumpstartClaimLoadingDismissed: (state) => ({
...state,
claimStatus: 'idle',
}),

jumpstartClaimErrorDismissed: (state) => ({
...state,
claimStatus: 'idle',
Expand Down
1 change: 1 addition & 0 deletions test/RootStateSchema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4800,6 +4800,7 @@
"claimStatus": {
"enum": [
"error",
"errorAlreadyClaimed",
"idle",
"loading"
],
Expand Down

0 comments on commit 8dd5080

Please sign in to comment.