Skip to content

Commit

Permalink
Add hooks for PSM actions (#84)
Browse files Browse the repository at this point in the history
* Add PSM actions to wagmi config and generate code

* Add psm action abi

* Add helper queries

* Add hooks for psm actions

* Take care of difference in decimals between assets token and gem

* Pass tokens decimals instead of whole tokens

* Use generate psm actions abi
  • Loading branch information
oskarvu authored Jun 12, 2024
1 parent 616d65f commit ccc39db
Show file tree
Hide file tree
Showing 12 changed files with 2,146 additions and 271 deletions.
1,808 changes: 1,537 additions & 271 deletions packages/app/src/config/contracts-generated.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { psmActionsAbi } from '@/config/contracts-generated'
import { toBigInt } from '@/utils/bigNumber'
import { queryOptions } from '@tanstack/react-query'
import { erc4626Abi } from 'viem'
import { Config } from 'wagmi'
import { readContract } from 'wagmi/actions'
import { CheckedAddress } from '../../types/CheckedAddress'
import { BaseUnitNumber } from '../../types/NumericValues'
import { calculateGemMinAmountOut } from './utils/calculateGemMinAmountOut'

export interface GemMinAmountOutKeyParams {
psmActions: CheckedAddress
gemDecimals: number
assetsTokenDecimals: number
sharesAmount: BaseUnitNumber
chainId: number
}

export interface GemMinAmountOutOptionsParams extends GemMinAmountOutKeyParams {
config: Config
}

// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function gemMinAmountOutQueryOptions({
psmActions,
gemDecimals,
assetsTokenDecimals,
sharesAmount,
chainId,
config,
}: GemMinAmountOutOptionsParams) {
return queryOptions({
queryKey: gemMinAmountOutQueryKey({ psmActions, gemDecimals, assetsTokenDecimals, sharesAmount, chainId }),
queryFn: async () => {
const vault = await readContract(config, {
address: psmActions,
abi: psmActionsAbi,
functionName: 'savingsToken',
})

const assetsAmount = await readContract(config, {
address: vault,
abi: erc4626Abi,
functionName: 'convertToAssets',
args: [toBigInt(sharesAmount)],
})

return calculateGemMinAmountOut({ gemDecimals, assetsTokenDecimals, assetsAmount })
},
})
}

export function gemMinAmountOutQueryKey({
gemDecimals,
assetsTokenDecimals,
psmActions,
sharesAmount,
chainId,
}: GemMinAmountOutKeyParams): unknown[] {
return ['gem-min-amount-out', gemDecimals, assetsTokenDecimals, psmActions, sharesAmount, chainId]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { psmActionsAbi, psmActionsAddress } from '@/config/contracts-generated'
import { BaseUnitNumber } from '@/domain/types/NumericValues'
import { getMockToken, testAddresses } from '@/test/integration/constants'
import { handlers } from '@/test/integration/mockTransport'
import { setupHookRenderer } from '@/test/integration/setupHookRenderer'
import { toBigInt } from '@/utils/bigNumber'
import { waitFor } from '@testing-library/react'
import { erc4626Abi } from 'viem'
import { mainnet } from 'viem/chains'
import { UseRedeemAndSwap } from './useRedeemAndSwap'

const gem = getMockToken({ address: testAddresses.token, decimals: 6 })
const assetsToken = getMockToken({ address: testAddresses.token2, decimals: 18 })
const account = testAddresses.alice
const sharesAmount = BaseUnitNumber(1)
const savingsToken = testAddresses.token3
const assetsAmount = BaseUnitNumber(1e18)

const hookRenderer = setupHookRenderer({
hook: UseRedeemAndSwap,
account,
handlers: [
handlers.chainIdCall({ chainId: mainnet.id }),
handlers.balanceCall({ balance: 0n, address: account }),
handlers.contractCall({
to: psmActionsAddress[mainnet.id],
abi: psmActionsAbi,
functionName: 'savingsToken',
result: savingsToken,
}),
handlers.contractCall({
to: savingsToken,
abi: erc4626Abi,
functionName: 'convertToAssets',
args: [toBigInt(sharesAmount)],
result: toBigInt(assetsAmount),
}),
],
args: { gem, assetsToken, sharesAmount },
})

describe(UseRedeemAndSwap.name, () => {
it('is not enabled for guest ', async () => {
const { result } = hookRenderer({ account: undefined })

await waitFor(() => {
expect(result.current.status.kind).toBe('disabled')
})
})

it('is not enabled for 0 gem value', async () => {
const { result } = hookRenderer({ args: { sharesAmount: BaseUnitNumber(0), gem, assetsToken } })

await waitFor(() => {
expect(result.current.status.kind).toBe('disabled')
})
})

it('is not enabled when explicitly disabled', async () => {
const { result } = hookRenderer({ args: { enabled: false, sharesAmount, gem, assetsToken } })

await waitFor(() => {
expect(result.current.status.kind).toBe('disabled')
})
})

it('redeems using psm actions', async () => {
const { result } = hookRenderer({
args: {
gem,
assetsToken,
sharesAmount,
},
extraHandlers: [
handlers.contractCall({
to: psmActionsAddress[mainnet.id],
abi: psmActionsAbi,
functionName: 'redeemAndSwap',
args: [account, toBigInt(sharesAmount), toBigInt(assetsAmount.dividedBy(1e12))],
from: account,
result: 1n,
}),
handlers.mineTransaction(),
],
})

await waitFor(() => {
expect(result.current.status.kind).toBe('ready')
})
expect((result.current as any).error).toBeUndefined()

result.current.write()

await waitFor(() => {
expect(result.current.status.kind).toBe('success')
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { psmActionsConfig } from '@/config/contracts-generated'
import { useContractAddress } from '@/domain/hooks/useContractAddress'
import { toBigInt } from '@/utils/bigNumber'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useAccount, useChainId, useConfig } from 'wagmi'
import { ensureConfigTypes, useWrite } from '../../hooks/useWrite'
import { BaseUnitNumber } from '../../types/NumericValues'
import { Token } from '../../types/Token'
import { balances } from '../../wallet/balances'
import { gemMinAmountOutQueryOptions } from './gemMinAmountOutQuery'

export interface UseRedeemAndSwapArgs {
gem: Token
assetsToken: Token
sharesAmount: BaseUnitNumber
onTransactionSettled?: () => void
enabled?: boolean
}

// @note: Redeem a specified amount of `savingsToken` from the `savingsToken`
// for `dai` and swap for `gem` in the PSM. Use this if you want to withdraw everything.
// @note: Assumes PSM swap rate between `dai` and `gem` is 1:1.
export function UseRedeemAndSwap({
gem,
assetsToken,
sharesAmount: _sharesAmount,
onTransactionSettled,
enabled: _enabled = true,
}: UseRedeemAndSwapArgs): ReturnType<typeof useWrite> {
const client = useQueryClient()
const wagmiConfig = useConfig()
const chainId = useChainId()

const psmActions = useContractAddress(psmActionsConfig.address)

const { address: receiver } = useAccount()
const sharesAmount = toBigInt(_sharesAmount)
const { data: gemMinAmountOut } = useQuery(
gemMinAmountOutQueryOptions({
gemDecimals: gem.decimals,
assetsTokenDecimals: assetsToken.decimals,
psmActions,
sharesAmount: _sharesAmount,
chainId,
config: wagmiConfig,
}),
)

const config = ensureConfigTypes({
address: psmActions,
abi: psmActionsConfig.abi,
functionName: 'redeemAndSwap',
args: [receiver!, sharesAmount, gemMinAmountOut!],
})
const enabled = _enabled && _sharesAmount.gt(0) && !!receiver && !!gemMinAmountOut

return useWrite(
{
...config,
enabled,
},
{
onTransactionSettled: async () => {
void client.invalidateQueries({
queryKey: balances({ wagmiConfig, chainId, account: receiver }).queryKey,
})

onTransactionSettled?.()
},
},
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { calculateGemMinAmountOut } from './calculateGemMinAmountOut'

describe(calculateGemMinAmountOut.name, () => {
const gemDecimals = 6
const assetsTokenDecimals = 18

it('caluclates the gem min value', () => {
expect(
calculateGemMinAmountOut({
gemDecimals,
assetsTokenDecimals,
assetsAmount: 10n ** 18n,
}),
).toBe(10n ** 6n)

expect(
calculateGemMinAmountOut({
gemDecimals,
assetsTokenDecimals,
assetsAmount: 999_000000000000000n,
}),
).toBe(999_000n)

// with rounding
expect(
calculateGemMinAmountOut({
gemDecimals,
assetsTokenDecimals,
assetsAmount: 123456_000000000000n,
}),
).toBe(123456n)

expect(
calculateGemMinAmountOut({
gemDecimals,
assetsTokenDecimals,
assetsAmount: 123456_555555555555n,
}),
).toBe(123456n)

expect(
calculateGemMinAmountOut({
gemDecimals,
assetsTokenDecimals,
assetsAmount: 123456_999999999999n,
}),
).toBe(123456n)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NormalizedUnitNumber } from '@/domain/types/NumericValues'
import { toBigInt } from '@/utils/bigNumber'
import BigNumber from 'bignumber.js'
import { calculateGemConversionFactor } from '../../utils/calculateGemConversionFactor'

export interface CalculateGemMinAmountOutParams {
gemDecimals: number
assetsTokenDecimals: number
assetsAmount: bigint
}

export function calculateGemMinAmountOut({
gemDecimals,
assetsTokenDecimals,
assetsAmount,
}: CalculateGemMinAmountOutParams): bigint {
const gemConversionFactor = calculateGemConversionFactor({ gemDecimals, assetsTokenDecimals })
const gemMinAmountOut = NormalizedUnitNumber(assetsAmount)
.dividedBy(gemConversionFactor)
.integerValue(BigNumber.ROUND_DOWN)
return toBigInt(gemMinAmountOut)
}
75 changes: 75 additions & 0 deletions packages/app/src/domain/psm-actions/useSwapAndDeposit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { psmActionsAbi, psmActionsAddress } from '@/config/contracts-generated'
import { BaseUnitNumber } from '@/domain/types/NumericValues'
import { getMockToken, testAddresses } from '@/test/integration/constants'
import { handlers } from '@/test/integration/mockTransport'
import { setupHookRenderer } from '@/test/integration/setupHookRenderer'
import { toBigInt } from '@/utils/bigNumber'
import { waitFor } from '@testing-library/react'
import { mainnet } from 'viem/chains'
import { useSwapAndDeposit } from './useSwapAndDeposit'

const gem = getMockToken({ address: testAddresses.token, decimals: 6 })
const assetsToken = getMockToken({ address: testAddresses.token2, decimals: 18 })
const account = testAddresses.alice
const gemAmount = BaseUnitNumber(1)

const hookRenderer = setupHookRenderer({
hook: useSwapAndDeposit,
account,
handlers: [handlers.chainIdCall({ chainId: mainnet.id }), handlers.balanceCall({ balance: 0n, address: account })],
args: { gem, assetsToken, gemAmount },
})

describe(useSwapAndDeposit.name, () => {
it('is not enabled for guest ', async () => {
const { result } = hookRenderer({ account: undefined })

await waitFor(() => {
expect(result.current.status.kind).toBe('disabled')
})
})

it('is not enabled for 0 gem value', async () => {
const { result } = hookRenderer({ args: { gemAmount: BaseUnitNumber(0), gem, assetsToken } })

await waitFor(() => {
expect(result.current.status.kind).toBe('disabled')
})
})

it('is not enabled when explicitly disabled', async () => {
const { result } = hookRenderer({ args: { enabled: false, gemAmount, gem, assetsToken } })

await waitFor(() => {
expect(result.current.status.kind).toBe('disabled')
})
})

it('deposits using psm actions', async () => {
const { result } = hookRenderer({
args: { gem, gemAmount, assetsToken },
extraHandlers: [
handlers.contractCall({
to: psmActionsAddress[mainnet.id],
abi: psmActionsAbi,
functionName: 'swapAndDeposit',
args: [account, toBigInt(gemAmount), toBigInt(gemAmount.multipliedBy(1e12))],
from: account,
result: 1n,
}),
handlers.mineTransaction(),
],
})

await waitFor(() => {
expect(result.current.status.kind).toBe('ready')
})
expect((result.current as any).error).toBeUndefined()

result.current.write()

await waitFor(() => {
expect(result.current.status.kind).toBe('success')
})
})
})
Loading

0 comments on commit ccc39db

Please sign in to comment.