Skip to content

Commit

Permalink
refactor: shared transactions sending saga for swap and send (#5067)
Browse files Browse the repository at this point in the history
### Description

This PR creates a `sendTransactionsSaga` which:
1. unlocks the wallet
2. takes some prepared transactions and sends them to the network
3. adds standby transactions 
4. returns the tx hashes

This is then shared by the swap and send sagas. A few changes were made
to the existing flows:
1. for unlocking the account, the send saga uses
`getConnectedUnlockedAccount`. since this also creates the signed
message when missing, i used this one in the shared saga. so the key
change is the swap saga used to use `unlockAccount` but now uses
`getConnectedUnlockedAccount` for unlocking the wallet.
2. the send flow used to use the viem `sendTransaction` method for
signing and sending the transaction, now it uses what the swap flow uses
(`signTransaction` with an explicitly set nonce, plus
`sendRawTransaction`) ([some
context](https://valora-app.slack.com/archives/C029Z1QMD7B/p1710155212351269))
3. both the send and swap sagas used to monitor the tx receipt and
manually settle the tx with `handleTransactionReceiptReceived`. i've
removed this monitoring because it is already triggered by the internal
pending tx's
[watcher](https://github.com/valora-inc/wallet/blob/main/src/transactions/saga.ts#L300).
the sagas now only wait for the tx receipts for analytics purposes.

This shared saga will also be used for the jumpstart stuff (not included
in this PR)

### Test plan

Manually tested swaps and send flows still work

### Related issues

- Relates to RET-994

### Backwards compatibility

Y

### Network scalability

Y
  • Loading branch information
kathaypacific committed Mar 12, 2024
1 parent 2af14ff commit a658959
Show file tree
Hide file tree
Showing 9 changed files with 455 additions and 272 deletions.
8 changes: 8 additions & 0 deletions e2e/src/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ export async function addComment(comment) {
*/
export async function confirmTransaction(commentText) {
try {
// the transaction should be visible because it is the most recent, however
// the comment text may be hidden while the transaction is pending. allow
// some time for the transaction to be settled, before asserting on the
// comment.
await waitFor(element(by.text(commentText)))
.toBeVisible()
.withTimeout(60 * 1000)

// getAttributes() for multiple elements only supported on iOS for Detox < 20.12.0
if (device.getPlatform() === 'ios') {
// Comment should be present in the feed
Expand Down
98 changes: 28 additions & 70 deletions src/send/saga.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import BigNumber from 'bignumber.js'
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 { EffectProviders, StaticProvider, throwError } from 'redux-saga-test-plan/providers'
import { call } from 'redux-saga/effects'
import { showError } from 'src/alert/actions'
import { CeloExchangeEvents, SendEvents } from 'src/analytics/Events'
Expand All @@ -18,12 +18,8 @@ import {
sendPaymentSuccess,
} from 'src/send/actions'
import { encryptCommentSaga, sendPaymentSaga } from 'src/send/saga'
import {
Actions as TransactionActions,
addStandbyTransaction,
transactionConfirmed,
} from 'src/transactions/actions'
import { NetworkId, TokenTransactionTypeV2, TransactionStatus } from 'src/transactions/types'
import { Actions as TransactionActions, addStandbyTransaction } from 'src/transactions/actions'
import { NetworkId, TokenTransactionTypeV2 } from 'src/transactions/types'
import { publicClient } from 'src/viem'
import { ViemWallet } from 'src/viem/getLockableWallet'
import { getViemWallet } from 'src/web3/contracts'
Expand All @@ -44,6 +40,7 @@ import {
mockCusdTokenId,
mockQRCodeRecipient,
} from 'test/values'
import { getTransactionCount } from 'viem/actions'

jest.mock('@celo/connect')

Expand Down Expand Up @@ -96,8 +93,8 @@ describe(sendPaymentSaga, () => {
}
const mockViemWallet = {
account: { address: mockAccount },
writeContract: jest.fn(),
sendTransaction: jest.fn(),
signTransaction: jest.fn(),
sendRawTransaction: jest.fn(),
} as any as ViemWallet
const mockTxHash: `0x${string}` = '0x12345678901234'
const mockTxReceipt = {
Expand All @@ -107,6 +104,19 @@ describe(sendPaymentSaga, () => {
gasUsed: BigInt(1e6),
effectiveGasPrice: BigInt(1e9),
}
function createDefaultProviders() {
const defaultProviders: (EffectProviders | StaticProvider)[] = [
[call(getConnectedUnlockedAccount), mockAccount],
[matchers.call.fn(getViemWallet), mockViemWallet],
[matchers.call.fn(getTransactionCount), 10],
[matchers.call.fn(mockViemWallet.signTransaction), '0xsomeSerialisedTransaction'],
[matchers.call.fn(mockViemWallet.sendRawTransaction), mockTxHash],
[matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt],
[matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }],
]

return defaultProviders
}

beforeEach(() => {
jest.clearAllMocks()
Expand All @@ -128,13 +138,7 @@ describe(sendPaymentSaga, () => {
async ({ fromModal, navigateFn }) => {
await expectSaga(sendPaymentSaga, { ...sendAction, fromModal })
.withState(createMockStore({}).getState())
.provide([
[call(getConnectedUnlockedAccount), mockAccount],
[matchers.call.fn(getViemWallet), mockViemWallet],
[matchers.call.fn(mockViemWallet.sendTransaction), mockTxHash],
[matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt],
[matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }],
])
.provide(createDefaultProviders())
.call(getViemWallet, networkConfig.viemChain.celo)
.put(
addStandbyTransaction({
Expand All @@ -155,26 +159,6 @@ describe(sendPaymentSaga, () => {
transactionHash: mockTxHash,
})
)
.put(
transactionConfirmed(
'mock',
{
transactionHash: mockTxHash,
block: '123',
status: TransactionStatus.Complete,
fees: [
{
type: 'SECURITY_FEE',
amount: {
value: '0.001',
tokenId: mockCeloTokenId,
},
},
],
},
1701102971000
)
)
.put(sendPaymentSuccess({ amount, tokenId: mockCusdTokenId }))
.run()

Expand All @@ -197,13 +181,7 @@ describe(sendPaymentSaga, () => {
it('sends a payment successfully for celo and logs a withdrawal event', async () => {
await expectSaga(sendPaymentSaga, { ...sendAction, tokenId: mockCeloTokenId })
.withState(createMockStore({}).getState())
.provide([
[call(getConnectedUnlockedAccount), mockAccount],
[matchers.call.fn(getViemWallet), mockViemWallet],
[matchers.call.fn(mockViemWallet.sendTransaction), mockTxHash],
[matchers.call.fn(publicClient.celo.waitForTransactionReceipt), mockTxReceipt],
[matchers.call.fn(publicClient.celo.getBlock), { timestamp: 1701102971 }],
])
.provide(createDefaultProviders())
.call(getViemWallet, networkConfig.viemChain.celo)
.put(
addStandbyTransaction({
Expand All @@ -224,26 +202,6 @@ describe(sendPaymentSaga, () => {
transactionHash: mockTxHash,
})
)
.put(
transactionConfirmed(
'mock',
{
transactionHash: mockTxHash,
block: '123',
status: TransactionStatus.Complete,
fees: [
{
type: 'SECURITY_FEE',
amount: {
value: '0.001',
tokenId: mockCeloTokenId,
},
},
],
},
1701102971000
)
)
.put(sendPaymentSuccess({ amount, tokenId: mockCeloTokenId }))
.run()

Expand All @@ -267,25 +225,25 @@ describe(sendPaymentSaga, () => {
it('fails if user cancels PIN input', async () => {
const account = '0x000123'
await expectSaga(sendPaymentSaga, sendAction)
.withState(createMockStore({}).getState())
.provide([
[call(getConnectedAccount), account],
[matchers.call.fn(unlockAccount), UnlockResult.CANCELED],
])
.put(showError(ErrorMessages.PIN_INPUT_CANCELED))
.put(sendPaymentFailure())
.run()
// 2 calls from showError, one with the error handler and one with the
// assertion above, there shouldn't be any other calls
expect(ValoraAnalytics.track).toHaveBeenCalledTimes(2)
// 1 call for start of send transaction plus 2 calls from showError, one
// with the error handler and one with the assertion above
expect(ValoraAnalytics.track).toHaveBeenCalledTimes(3)
})

it('fails if sendTransaction throws', async () => {
it('fails if sendRawTransaction throws', async () => {
await expectSaga(sendPaymentSaga, sendAction)
.withState(createMockStore({}).getState())
.provide([
[call(getConnectedUnlockedAccount), mockAccount],
[matchers.call.fn(getViemWallet), mockViemWallet],
[matchers.call.fn(mockViemWallet.sendTransaction), throwError(new Error('tx failed'))],
[matchers.call.fn(mockViemWallet.sendRawTransaction), throwError(new Error('tx failed'))],
...createDefaultProviders(),
])
.call(getViemWallet, networkConfig.viemChain.celo)
.put(sendPaymentFailure())
Expand Down
101 changes: 38 additions & 63 deletions src/send/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,10 @@ import {
getTokenInfoByAddress,
tokenAmountInSmallestUnit,
} from 'src/tokens/saga'
import { tokensByIdSelector } from 'src/tokens/selectors'
import { TokenBalance, fetchTokenBalances } from 'src/tokens/slice'
import { getTokenId } from 'src/tokens/utils'
import { addStandbyTransaction } from 'src/transactions/actions'
import { handleTransactionReceiptReceived, sendAndMonitorTransaction } from 'src/transactions/saga'
import { BaseStandbyTransaction, addStandbyTransaction } from 'src/transactions/actions'
import { sendAndMonitorTransaction } from 'src/transactions/saga'
import {
TokenTransactionTypeV2,
TransactionContext,
Expand All @@ -41,19 +40,16 @@ import Logger from 'src/utils/Logger'
import { ensureError } from 'src/utils/ensureError'
import { safely } from 'src/utils/safely'
import { publicClient } from 'src/viem'
import { getFeeCurrencyToken } from 'src/viem/prepareTransactions'
import { getPreparedTransaction } from 'src/viem/preparedTransactionSerialization'
import { getContractKit, getViemWallet } from 'src/web3/contracts'
import networkConfig from 'src/web3/networkConfig'
import { sendPreparedTransactions } from 'src/viem/saga'
import { getContractKit } from 'src/web3/contracts'
import networkConfig, { networkIdToNetwork } from 'src/web3/networkConfig'
import { getConnectedUnlockedAccount } from 'src/web3/saga'
import { getNetworkFromNetworkId } from 'src/web3/utils'
import { call, put, select, spawn, take, takeEvery, takeLeading } from 'typed-redux-saga'
import { call, put, spawn, take, takeEvery, takeLeading } from 'typed-redux-saga'
import * as utf8 from 'utf8'
import { TransactionReceipt } from 'viem'

const TAG = 'send/saga'
export const TAG = 'send/saga'

export function* watchQrCodeShare() {
function* watchQrCodeShare() {
while (true) {
const action = (yield* take(Actions.QRCODE_SHARE)) as ShareQRCodeAction
try {
Expand Down Expand Up @@ -177,7 +173,6 @@ export function* sendPaymentSaga({
preparedTransaction: serializablePreparedTransaction,
}: SendPaymentAction) {
try {
yield* call(getConnectedUnlockedAccount)
SentryTransactionHub.startTransaction(SentryTransaction.send_payment)
const context = newTransactionContext(TAG, 'Send payment')
const recipientAddress = recipient.address
Expand All @@ -188,25 +183,32 @@ export function* sendPaymentSaga({
}

const tokenInfo = yield* call(getTokenInfo, tokenId)
const network = getNetworkFromNetworkId(tokenInfo?.networkId)
if (!tokenInfo || !network) {
throw new Error('Unknown token network')
if (!tokenInfo) {
throw new Error(`Could not find token info for token id: ${tokenId}`)
}
const networkId = tokenInfo.networkId

const wallet = yield* call(getViemWallet, networkConfig.viemChain[network])

if (!wallet.account) {
// this should never happen
throw new Error('no account found in the wallet')
}
const createStandbyTransaction = (
transactionHash: string,
feeCurrencyId?: string
): BaseStandbyTransaction => ({
__typename: 'TokenTransferV3',
type: TokenTransactionTypeV2.Sent,
context,
networkId: tokenInfo.networkId,
amount: {
value: amount.negated().toString(),
tokenAddress: tokenInfo.address ?? undefined,
tokenId,
},
address: recipientAddress,
metadata: {
comment,
},
transactionHash,
feeCurrencyId,
})

ValoraAnalytics.track(SendEvents.send_tx_start)

const preparedTransaction = getPreparedTransaction(serializablePreparedTransaction)
const tokensById = yield* select((state) => tokensByIdSelector(state, [networkId]))
const feeCurrencyId = getFeeCurrencyToken([preparedTransaction], networkId, tokensById)?.tokenId

Logger.debug(
`${TAG}/sendPaymentSaga`,
'Executing send transaction',
Expand All @@ -216,45 +218,18 @@ export function* sendPaymentSaga({
amount
)

const hash = yield* call([wallet, 'sendTransaction'], preparedTransaction as any)

Logger.debug(`${TAG}/sendPaymentSaga`, 'Successfully sent transaction to the network', hash)

yield* put(
addStandbyTransaction({
__typename: 'TokenTransferV3',
type: TokenTransactionTypeV2.Sent,
context,
networkId,
amount: {
value: amount.negated().toString(),
tokenAddress: tokenInfo.address ?? undefined,
tokenId,
},
address: recipientAddress,
metadata: {
comment,
},
transactionHash: hash,
feeCurrencyId,
})
const [hash] = yield* call(
sendPreparedTransactions,
[serializablePreparedTransaction],
tokenInfo.networkId,
[createStandbyTransaction]
)

const receipt: TransactionReceipt = yield* call(
[publicClient[network], 'waitForTransactionReceipt'],
const receipt = yield* call(
[publicClient[networkIdToNetwork[tokenInfo.networkId]], 'waitForTransactionReceipt'],
{ hash }
)

Logger.debug(`${TAG}/sendPaymentSaga`, 'Got send transaction receipt', receipt)

yield* call(
handleTransactionReceiptReceived,
context.id,
receipt,
networkConfig.networkToNetworkId[network],
feeCurrencyId
)

if (receipt.status === 'reverted') {
throw new Error(`Send transaction reverted: ${hash}`)
}
Expand Down Expand Up @@ -310,7 +285,7 @@ export function* encryptCommentSaga({ comment, fromAddress, toAddress }: Encrypt
yield* put(encryptCommentComplete(encryptedComment))
}

export function* watchSendPayment() {
function* watchSendPayment() {
yield* takeLeading(Actions.SEND_PAYMENT, safely(sendPaymentSaga))
}

Expand Down
Loading

0 comments on commit a658959

Please sign in to comment.