Skip to content

Commit

Permalink
Add basic implementation to stake/unstake
Browse files Browse the repository at this point in the history
  • Loading branch information
gndelia committed Jan 17, 2025
1 parent 3a7cb4c commit 22ea193
Show file tree
Hide file tree
Showing 5 changed files with 328 additions and 4 deletions.
9 changes: 7 additions & 2 deletions webapp/hooks/useHemiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
hemiSepolia,
} from 'hemi-viem'
import { useMemo } from 'react'
import { hemiPublicExtraActions } from 'utils/hemiClientExtraActions'
import {
hemiPublicExtraActions,
hemiWalletExtraActions,
} from 'utils/hemiClientExtraActions'
import { type WalletClient, type PublicClient } from 'viem'
import { usePublicClient, useWalletClient } from 'wagmi'

Expand Down Expand Up @@ -38,7 +41,9 @@ export const useHemiClient = function () {
}

const walletClientToHemiClient = (walletClient: WalletClient) =>
walletClient.extend(hemiWalletBitcoinTunnelManagerActions())
walletClient
.extend(hemiWalletBitcoinTunnelManagerActions())
.extend(hemiWalletExtraActions())

export const useHemiWalletClient = function () {
const hemi = useHemi()
Expand Down
124 changes: 124 additions & 0 deletions webapp/test/utils/stake.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { hemi, hemiSepolia } from 'hemi-viem'
import { HemiPublicClient, HemiWalletClient } from 'hooks/useHemiClient'
import { EvmToken } from 'types/token'
import { canSubmit, stake } from 'utils/stake'
import { getErc20TokenBalance } from 'utils/token'
import { parseUnits, zeroAddress } from 'viem'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('utils/token', () => ({
getErc20TokenBalance: vi.fn(),
}))

// @ts-expect-error Adding minimal properties needed
const token: EvmToken = {
chainId: hemiSepolia.id,
decimals: 18,
symbol: 'fakeToken',
}

describe('utils/stake', function () {
describe('canSubmit', function () {
it('should return error if amount is a negative value', function () {
const result = canSubmit({
amount: BigInt(-123),
balance: BigInt(1000),
connectedChainId: hemiSepolia.id,
token,
})
expect(result).toEqual({ error: 'amount-less-equal-than-0' })
})

it('should return error if amount is equal to 0', function () {
const result = canSubmit({
amount: BigInt(0),
balance: BigInt(1000),
connectedChainId: hemiSepolia.id,
token,
})
expect(result).toEqual({ error: 'amount-less-equal-than-0' })
})

it('should return error if chain ID does not match', function () {
const result = canSubmit({
amount: BigInt(1),
balance: BigInt(1000),
connectedChainId: hemi.id,
token,
})
expect(result).toEqual({ error: 'wrong-chain' })
})

it('should return error if balance is less than or equal to 0', function () {
const result = canSubmit({
amount: BigInt(1),
balance: BigInt(0),
connectedChainId: hemiSepolia.id,
token,
})
expect(result).toEqual({ error: 'not-enough-balance' })
})

it('should return error if balance is less than the amount', function () {
const result = canSubmit({
amount: BigInt(10),
balance: BigInt(9),
connectedChainId: hemiSepolia.id,
token,
})
expect(result).toEqual({ error: 'amount-larger-than-balance' })
})

it('should return empty object if all conditions are met', function () {
const result = canSubmit({
amount: BigInt(1),
balance: BigInt(1000),
connectedChainId: hemiSepolia.id,
token,
})
expect(result).toEqual({})
})
})

describe('stake', function () {
beforeEach(function () {
vi.clearAllMocks()
})

// @ts-expect-error only add the minimum values required
const hemiPublicClient: HemiPublicClient = { chain: hemiSepolia }
// @ts-expect-error only add the minimum values required
const hemiWalletClient: HemiWalletClient = {
stakeToken: vi.fn(),
}

it('should throw error if the consumer can not stake', async function () {
getErc20TokenBalance.mockResolvedValue(BigInt(0))
await expect(
stake({
amount: '1',
from: zeroAddress,
hemiPublicClient,
hemiWalletClient,
token,
}),
).rejects.toThrow('not-enough-balance')
})

it('should call stakeToken if all conditions are met', async function () {
getErc20TokenBalance.mockResolvedValue(parseUnits('10', token.decimals))
await stake({
amount: '1',
from: zeroAddress,
hemiPublicClient,
hemiWalletClient,
token,
})
expect(hemiWalletClient.stakeToken).toHaveBeenCalledWith({
amount: parseUnits('1', token.decimals),
from: zeroAddress,
token,
})
})
})
})
24 changes: 23 additions & 1 deletion webapp/utils/hemiClientExtraActions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
import { type Client } from 'viem'
import { EvmToken } from 'types/token'
import { Address, type Client, Hash } from 'viem'

// Fake resolving to a hash address so typings are defined. Here, a TX hash from the blockchain would be returned
// Note that this won't be in checksum format
const generateFakeTxHashAddress = () =>
'0x'.concat(
[...Array(64)]
.map(() => Math.floor(Math.random() * 16).toString(16))
.join('') as Hash,
)

export const hemiPublicExtraActions =
({ defaultBitcoinVaults }) =>
Expand All @@ -11,3 +21,15 @@ export const hemiPublicExtraActions =
// @ts-expect-error can't use PublicClient in parameter as fails to compile.
Promise.resolve(defaultBitcoinVaults[client.chain.id]),
})

// This will eventually be removed. See issue below
export const hemiWalletExtraActions = () => () => ({
// TODO TBD real implementation. See https://github.com/hemilabs/ui-monorepo/issues/774
// eslint-disable-next-line @typescript-eslint/no-unused-vars
stakeToken: (_: { amount: bigint; from: Address; token: EvmToken }) =>
Promise.resolve(generateFakeTxHashAddress()),
// TODO TBD real implementation. See https://github.com/hemilabs/ui-monorepo/issues/774
// eslint-disable-next-line @typescript-eslint/no-unused-vars
unstakeToken: (_: { amount: bigint; from: Address; token: EvmToken }) =>
Promise.resolve(generateFakeTxHashAddress()),
})
149 changes: 149 additions & 0 deletions webapp/utils/stake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
type HemiPublicClient,
type HemiWalletClient,
} from 'hooks/useHemiClient'
import { EvmToken } from 'types/token'
import { getErc20TokenBalance } from 'utils/token'
import { type Address, Chain, parseUnits } from 'viem'

/**
* Determines if a user is able to stake or unstake a token
*
* @param params All the parameters needed to determine if a user can stake or unstake a token
* @param amount The amount of tokens to stake/unstake
* @param balance The balance the user has of the token
* @param connectedChainId The chain Id the user is connected to
* @param token The token to stake/unstake
*/
export const canSubmit = function ({
amount,
balance,
connectedChainId,
token,
}: {
amount: bigint
balance: bigint
connectedChainId: Chain['id']
token: EvmToken
}) {
if (amount <= 0) {
return { error: 'amount-less-equal-than-0' }
}
// this chain Id comes from the hemi client. It verifies that the token
// is on expected hemi chain (hemi / testnet).
if (connectedChainId !== token.chainId) {
return { error: 'wrong-chain' }
}
if (balance <= 0) {
return { error: 'not-enough-balance' }
}
if (amount > balance) {
return { error: 'amount-larger-than-balance' }
}
return {}
}

const validateOperation = async function ({
amount,
from,
hemiPublicClient,
token,
}: {
amount: bigint
from: Address
hemiPublicClient: HemiPublicClient
token: EvmToken
}) {
const balance = await getErc20TokenBalance({
address: from,
// It works in runtime, but Typescript fails to interpret HemiPublicClient as a Client.
// I've seen that the typings change in future viem's version, so this may be soon fixed
// @ts-expect-error hemiPublicClient is Client
client: hemiPublicClient,
token,
})
const { error } = canSubmit({
amount,
balance,
connectedChainId: hemiPublicClient.chain.id,
token,
})
if (error) {
throw new Error(error)
}
}

/**
* Stakes an amount of a token in Hemi.
* @param params All the parameters needed to determine if a user can stake a token
* @param amount The amount of tokens to stake
* @param from The address of the user that will stake
* @param hemiPublicClient Hemi public client for read-only calls
* @param hemiWalletClient Hemi Wallet client for signing transactions
* @param token The token to stake
*/
export const stake = async function ({
amount,
from,
hemiPublicClient,
hemiWalletClient,
token,
}: {
amount: string
from: Address
hemiPublicClient: HemiPublicClient
hemiWalletClient: HemiWalletClient
token: EvmToken
}) {
const amountUnits = parseUnits(amount, token.decimals)
await validateOperation({
amount: amountUnits,
from,
hemiPublicClient,
token,
})

return hemiWalletClient.stakeToken({
amount: amountUnits,
from,
token,
})
}

/**
* Unstakes an amount of a token in Hemi.
* @param params All the parameters needed to determine if a user can unstake a token
* @param amount The amount of tokens to stake
* @param from The address of the user that will unstake
* @param hemiPublicClient Hemi public client for read-only calls
* @param hemiWalletClient Hemi Wallet client for signing transactions
* @param token The token to stake
*/
export const unstake = async function ({
amount,
from,
hemiPublicClient,
hemiWalletClient,
token,
}: {
amount: string
from: Address
hemiPublicClient: HemiPublicClient
hemiWalletClient: HemiWalletClient
token: EvmToken
}) {
const amountUnits = parseUnits(amount, token.decimals)
// Here I am assuming that when the user stakes, we get a staked token
// that can later be used to withdraw. That's why we need to check the balance of this staked token inside "validate operation"
await validateOperation({
amount: amountUnits,
from,
hemiPublicClient,
token,
})
return hemiWalletClient.unstakeToken({
amount: amountUnits,
from,
token,
})
}
26 changes: 25 additions & 1 deletion webapp/utils/token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { tokenList, NativeTokenSpecialAddressOnL2 } from 'tokenList'
import { EvmToken, Token } from 'types/token'
import { isAddress, isAddressEqual, zeroAddress } from 'viem'
import {
type Address,
type Client,
erc20Abi,
isAddress,
isAddressEqual,
zeroAddress,
} from 'viem'
import { readContract } from 'viem/actions'

export const isNativeAddress = (address: string) =>
address === zeroAddress ||
Expand Down Expand Up @@ -32,3 +40,19 @@ export const getTokenByAddress = function (

export const isEvmToken = (token: Token): token is EvmToken =>
typeof token.chainId === 'number'

export const getErc20TokenBalance = ({
address,
client,
token,
}: {
address: Address
client: Client
token: EvmToken
}) =>
readContract(client, {
abi: erc20Abi,
address: token.address as Address,
args: [address],
functionName: 'balanceOf',
})

0 comments on commit 22ea193

Please sign in to comment.