Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CoW AMM multisig with tx batch #181

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { AddLiquiditySummary } from './AddLiquiditySummary'
import { useAddLiquidityReceipt } from '@repo/lib/modules/transactions/transaction-steps/receipts/receipt.hooks'
import { useUserAccount } from '@repo/lib/modules/web3/UserAccountProvider'
import { useTokens } from '@repo/lib/modules/tokens/TokensProvider'
import { TxBatchAlert } from '@repo/lib/shared/components/alerts/TxBatchAlert'
import { useShouldBatchTransactions } from '@repo/lib/modules/web3/safe.hooks'

type Props = {
isOpen: boolean
Expand All @@ -43,6 +45,7 @@ export function AddLiquidityModal({
setInitialHumanAmountsIn,
} = useAddLiquidity()
const { pool, chain } = usePool()
const shouldBatchTransactions = useShouldBatchTransactions(pool)
const { redirectToPoolPage } = usePoolRedirect(pool)
const { userAddress } = useUserAccount()
const { stopTokenPricePolling } = useTokens()
Expand Down Expand Up @@ -88,7 +91,11 @@ export function AddLiquidityModal({

<ModalContent {...getStylesForModalContentWithStepTracker(isDesktop && hasQuoteContext)}>
{isDesktop && hasQuoteContext && (
<DesktopStepTracker chain={pool.chain} transactionSteps={transactionSteps} />
<DesktopStepTracker
chain={pool.chain}
isTxBatch={shouldBatchTransactions}
transactionSteps={transactionSteps}
/>
)}
<TransactionModalHeader
chain={pool.chain}
Expand All @@ -99,6 +106,7 @@ export function AddLiquidityModal({
/>
<ModalCloseButton />
<ModalBody>
<TxBatchAlert currentStep={transactionSteps.currentStep} mb="sm" />
<AddLiquiditySummary {...receiptProps} />
</ModalBody>
<ActionModalFooter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
TransactionLabels,
TransactionStep,
} from '@repo/lib/modules/transactions/transaction-steps/lib'
import { TransactionBatchButton } from '@repo/lib/modules/transactions/transaction-steps/safe/TransactionBatchButton'
import { useTenderly } from '@repo/lib/modules/web3/useTenderly'
import { sentryMetaForWagmiSimulation } from '@repo/lib/shared/utils/query-errors'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { usePool } from '../../PoolProvider'
import {
AddLiquidityBuildQueryParams,
useAddLiquidityBuildCallDataQuery,
} from './queries/useAddLiquidityBuildCallDataQuery'
import { usePool } from '../../PoolProvider'
import { useTenderly } from '@repo/lib/modules/web3/useTenderly'

export const addLiquidityStepId = 'add-liquidity'

Expand Down Expand Up @@ -79,6 +80,22 @@ export function useAddLiquidityStep(params: AddLiquidityStepParams): Transaction
txConfig={buildCallDataQuery.data}
/>
),
// The following fields are only used within Safe App
renderBatchAction: (currentStep: TransactionStep) => {
return (
<TransactionBatchButton
chainId={chainId}
currentStep={currentStep}
id={addLiquidityStepId}
labels={labels}
/>
)
},
// Last step in smart account batch
isBatchEnd: true,
batchableTxCall: buildCallDataQuery.data
? { data: buildCallDataQuery.data.data, to: buildCallDataQuery.data.to }
: undefined,
}),
[transaction, simulationQuery.data, buildCallDataQuery.data]
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import { requiresPermit2Approval } from '../../pool.helpers'
import { LiquidityActionHelpers } from '../LiquidityActionHelpers'
import { AddLiquidityStepParams, useAddLiquidityStep } from './useAddLiquidityStep'
import { useSignPermit2AddStep } from './useSignPermit2AddStep'
import { useShouldBatchTransactions } from '@repo/lib/modules/web3/safe.hooks'
import { TransactionStep } from '@repo/lib/modules/transactions/transaction-steps/lib'
import { hasSomePendingNestedTxInBatch } from '@repo/lib/modules/transactions/transaction-steps/safe/safe.helpers'

type AddLiquidityStepsParams = AddLiquidityStepParams & {
helpers: LiquidityActionHelpers
Expand All @@ -23,6 +26,7 @@ export function useAddLiquiditySteps({
simulationQuery,
}: AddLiquidityStepsParams) {
const { pool, chainId, chain } = usePool()
const shouldBatchTransactions = useShouldBatchTransactions(pool)
const { slippage } = useUserSettings()
const relayerMode = useRelayerMode(pool)
const shouldSignRelayerApproval = useShouldSignRelayerApproval(chainId, relayerMode)
Expand Down Expand Up @@ -64,17 +68,23 @@ export function useAddLiquiditySteps({
slippage,
})

const addSteps =
const addSteps: TransactionStep[] =
isPermit2 && signPermit2Step ? [signPermit2Step, addLiquidityStep] : [addLiquidityStep]

const steps = useMemo(() => {
addLiquidityStep.nestedSteps = tokenApprovalSteps
const approveAndAddSteps =
shouldBatchTransactions && hasSomePendingNestedTxInBatch(addLiquidityStep)
? [addLiquidityStep] // Hide token approvals when batching
: [...tokenApprovalSteps, ...addSteps]

const steps = useMemo<TransactionStep[]>(() => {
if (relayerMode === 'approveRelayer') {
return [approveRelayerStep, ...tokenApprovalSteps, ...addSteps]
return [approveRelayerStep, ...approveAndAddSteps]
} else if (shouldSignRelayerApproval) {
return [signRelayerStep, ...tokenApprovalSteps, ...addSteps]
return [signRelayerStep, ...approveAndAddSteps]
}

return [...tokenApprovalSteps, ...addSteps]
return [...approveAndAddSteps]
}, [
relayerMode,
shouldSignRelayerApproval,
Expand Down
26 changes: 24 additions & 2 deletions packages/lib/modules/tokens/approvals/useTokenApprovalSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { GqlChain } from '@repo/lib/shared/services/api/generated/graphql'
import { isSameAddress } from '@repo/lib/shared/utils/addresses'
import { sentryMetaForWagmiSimulation } from '@repo/lib/shared/utils/query-errors'
import { useMemo } from 'react'
import { Address } from 'viem'
import { Address, encodeFunctionData, erc20Abi } from 'viem'
import { ManagedErc20TransactionButton } from '../../transactions/transaction-steps/TransactionButton'
import { TransactionStep } from '../../transactions/transaction-steps/lib'
import { TransactionStep, TxCall } from '../../transactions/transaction-steps/lib'
import { ManagedErc20TransactionInput } from '../../web3/contracts/useManagedErc20Transaction'
import { useTokenAllowances } from '../../web3/useTokenAllowances'
import { useUserAccount } from '../../web3/UserAccountProvider'
Expand Down Expand Up @@ -104,12 +104,15 @@ export function useTokenApprovalSteps({
),
}

const args = props.args as [Address, bigint]

return {
id,
stepType: 'tokenApproval',
labels,
isComplete,
renderAction: () => <ManagedErc20TransactionButton id={id} key={id} {...props} />,
batchableTxCall: buildBatchableTxCall({ tokenAddress, args }),
onSuccess: () => tokenAllowances.refetchAllowances(),
} as const satisfies TransactionStep
})
Expand All @@ -120,3 +123,22 @@ export function useTokenApprovalSteps({
steps,
}
}

// Only used when wallet supports atomic bath (smart accounts like Gnosis Safe)
function buildBatchableTxCall({
tokenAddress,
args,
}: {
tokenAddress: Address
args: readonly [Address, bigint]
}): TxCall {
const data = encodeFunctionData({
abi: erc20Abi, // TODO: support usdtAbi
functionName: 'approve',
args,
})
return {
to: tokenAddress,
data: data,
}
}
39 changes: 22 additions & 17 deletions packages/lib/modules/transactions/RecentTransactionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import { captureFatalError } from '@repo/lib/shared/utils/query-errors'
import { secs } from '@repo/lib/shared/utils/time'
import { AlertStatus, ToastId, useToast } from '@chakra-ui/react'
import { keyBy, orderBy, take } from 'lodash'
import React, { ReactNode, createContext, useCallback, useEffect, useState } from 'react'
import { ReactNode, createContext, useCallback, useEffect, useState } from 'react'
import { Hash } from 'viem'
import { useConfig, usePublicClient } from 'wagmi'
import { waitForTransactionReceipt } from 'wagmi/actions'
import { getWaitForReceiptTimeout } from '../web3/contracts/wagmi-helpers'
import { TransactionStatus as SafeTxStatus } from '@safe-global/safe-apps-sdk'

export type RecentTransactionsResponse = ReturnType<typeof _useRecentTransactions>
export const TransactionsContext = createContext<RecentTransactionsResponse | null>(null)
Expand All @@ -34,6 +35,8 @@ export type TransactionStatus =
| 'timeout'
| 'unknown'

export type SafeTransactionStatus = SafeTxStatus

export type TrackedTransaction = {
hash: Hash
label?: string
Expand Down Expand Up @@ -146,19 +149,23 @@ export function _useRecentTransactions() {
// add a toast for this transaction, rather than emitting a new toast
// on updates for the same transaction, we will modify the same toast
// using updateTrackedTransaction.
const toastId = toast({
title: trackedTransaction.label,
description: trackedTransaction.description,
status: 'loading',
duration: trackedTransaction.duration ?? null,
isClosable: true,
render: ({ ...rest }) => (
<Toast
linkUrl={getBlockExplorerTxUrl(trackedTransaction.hash, trackedTransaction.chain)}
{...rest}
/>
),
})
let toastId: ToastId | undefined = undefined
// Edge case: if the transaction is confirmed we don't show the toast
if (trackedTransaction.status !== 'confirmed') {
toastId = toast({
title: trackedTransaction.label,
description: trackedTransaction.description,
status: 'loading',
duration: trackedTransaction.duration ?? null,
isClosable: true,
render: ({ ...rest }) => (
<Toast
linkUrl={getBlockExplorerTxUrl(trackedTransaction.hash, trackedTransaction.chain)}
{...rest}
/>
),
})
}

if (!trackedTransaction.hash) {
throw new Error('Attempted to add a transaction to the cache without a hash.')
Expand Down Expand Up @@ -187,10 +194,8 @@ export function _useRecentTransactions() {
// attempt to find this transaction in the cache
const cachedTransaction = transactions[hash]

// seems like we couldn't find this transaction in the cache
// TODO discuss behaviour around this
if (!cachedTransaction) {
console.log({ hash, transactions })
console.log('Cannot update a cached tracked transaction', { hash, transactions })
throw new Error('Cannot update a cached tracked transaction that does not exist.')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ export function _useTransactionState() {
function updateTransaction(k: string, v: ManagedResult) {
// if creating transaction
if (!transactionMap.has(k)) {
/*
// Safe App edge case when the transaction is created with success status
if (v.result.status === 'success') {
setTransactionMap(new Map(transactionMap.set(k, v)))
} else {
/*
When there was a previous transaction useWriteContract() will return the execution hash from that previous transaction,
So we need to reset it to avoid issues with multiple "managedTransaction" steps running in sequence.
More info: https://wagmi.sh/react/api/hooks/useWriteContract#data
*/
v = resetTransaction(v)
v = resetTransaction(v)
}
}

// Avoid updating transaction if it's already successful (avoids unnecessary re-renders and side-effects)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { ConnectWallet } from '@repo/lib/modules/web3/ConnectWallet'
import { useUserAccount } from '@repo/lib/modules/web3/UserAccountProvider'
import { Button, VStack } from '@chakra-ui/react'
import { ManagedResult, TransactionLabels, TransactionState, getTransactionState } from './lib'
import { useChainSwitch } from '@repo/lib/modules/web3/useChainSwitch'
import { NetworkSwitchButton, useChainSwitch } from '@repo/lib/modules/web3/useChainSwitch'
import { GenericError } from '@repo/lib/shared/components/errors/GenericError'
import { getGqlChain } from '@repo/lib/config/app.config'
import { TransactionTimeoutError } from '@repo/lib/shared/components/errors/TransactionTimeoutError'
import { useState } from 'react'
import { ensureError } from '@repo/lib/shared/utils/errors'
import { LabelWithIcon } from '@repo/lib/shared/components/btns/button-group/LabelWithIcon'
import { getTransactionButtonLabel } from './transaction-button.helpers'

interface Props {
step: { labels: TransactionLabels } & ManagedResult
Expand All @@ -20,8 +21,7 @@ export function TransactionStepButton({ step }: Props) {
const { chainId, simulation, labels, executeAsync } = step
const [executionError, setExecutionError] = useState<Error>()
const { isConnected } = useUserAccount()
const { shouldChangeNetwork, NetworkSwitchButton, networkSwitchButtonProps } =
useChainSwitch(chainId)
const { shouldChangeNetwork } = useChainSwitch(chainId)
const isTransactButtonVisible = isConnected
const transactionState = getTransactionState(step)
const isButtonLoading =
Expand Down Expand Up @@ -51,33 +51,14 @@ export function TransactionStepButton({ step }: Props) {

function getButtonLabel() {
if (executionError) return labels.init
// sensible defaults for loading / confirm if not provided
const relevantLabel = labels[transactionState as keyof typeof labels]

if (!relevantLabel) {
switch (transactionState) {
case TransactionState.Preparing:
return 'Preparing'
case TransactionState.Loading:
return 'Confirm in wallet'
case TransactionState.Confirming:
return 'Confirming transaction'
case TransactionState.Error:
return labels.init
case TransactionState.Completed:
return labels.confirmed || 'Confirmed transaction'
}
}
return relevantLabel
return getTransactionButtonLabel({ transactionState, labels })
}

return (
<VStack width="full">
{transactionState === TransactionState.Error && <TransactionError step={step} />}
{!isTransactButtonVisible && <ConnectWallet width="full" />}
{shouldChangeNetwork && isTransactButtonVisible && (
<NetworkSwitchButton {...networkSwitchButtonProps} />
)}
{isTransactButtonVisible && shouldChangeNetwork && <NetworkSwitchButton chainId={chainId} />}
{!shouldChangeNetwork && isTransactButtonVisible && (
<Button
isDisabled={isButtonDisabled}
Expand Down
42 changes: 41 additions & 1 deletion packages/lib/modules/transactions/transaction-steps/lib.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { TransactionBundle } from '@repo/lib/modules/web3/contracts/contract.types'
import { ReactNode } from 'react'
import { LockActionType } from '../../vebal/lock/steps/lock-steps.utils'
import { BaseTransaction } from '@safe-global/safe-apps-sdk'
import { Address, Hash } from 'viem'

export enum TransactionState {
Ready = 'init',
Expand Down Expand Up @@ -77,6 +79,44 @@ export type StepDetails = {
batchApprovalTokens?: string[]
}

export type TxCall = {
to: Address
data: Hash
}

export type SafeAppTx = BaseTransaction

export type TxBatch = SafeAppTx[]

/*
Smart accounts, like Gnosis Safe, support batching multiple transactions into an atomic one
We use the gnosis Safe Apps SDK to implement that feature:
Repo and docs: https://github.com/safe-global/safe-apps-sdk/tree/main/packages/safe-apps-sdk#safe-apps-sdk

Nice to have:
It would be great to generalize the implementation to be supported by non Gnosis Safe,
as other wallets like Coinbase wallet also support Smart Account features,
However we found that wagmi is not fully supporting this features yet.
More info:
https://wagmi.sh/react/api/hooks/useSendCalls
https://wagmi.sh/react/api/hooks/useCallsStatus
*/
type MaybeBatchableTx = {
// TxCall representation of the TransactionStep when it must be executed inside a tx batch
batchableTxCall?: TxCall
/*
true when the current transaction step is the last one in the batch
Example:
we have 3 transactions in a batch (2 token approval transactions and 1 add liquidity transaction)
the add liquidity transaction should have isBatchEnd = true
*/
isBatchEnd?: boolean
// Used instead of renderAction when the step is a batch with uncompleted nested steps
renderBatchAction?: (currentStep: TransactionStep) => React.ReactNode
// Example: token approval steps are nested inside addLiquidity step
nestedSteps?: TransactionStep[]
}

export type TransactionStep = {
id: string
stepType: StepType
Expand All @@ -88,7 +128,7 @@ export type TransactionStep = {
onSuccess?: () => void
onActivated?: () => void
onDeactivated?: () => void
}
} & MaybeBatchableTx

export function getTransactionState(transactionBundle?: TransactionBundle): TransactionState {
if (!transactionBundle) return TransactionState.Ready
Expand Down
Loading
Loading