Skip to content
Merged
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
182 changes: 182 additions & 0 deletions src/components/cards/staking/delegateCard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import {useEffect, useState} from 'react'
import logger from '../../../utils/logger'
import {getFixedTxFromBytes, getTransactionWitnessSetFromBytes} from '../../../utils/cslTools'
import ApiCardWithModal from '../apiCardWithModal'
import {ModalWindowContent} from '../../ui-constants'
import InputWithLabel from '../../inputWithLabel'
import {firstOrThrow} from '../../../utils/helpFunctions'
import {fetchAccountInfo} from './logic/withdraw'
import {buildDelegationTx, getStakeKeyHashFromPubKey, resolveDelegationStakeKey} from './logic/delegate'

const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => {
const [networkType, setNetworkType] = useState('preprod')
const [showNetworkSelection, setShowNetworkSelection] = useState(false)
const [waitingAccountInfo, setWaitingAccountInfo] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [showSuccessInfo, setShowSuccessInfo] = useState(false)
const [stakeRegistered, setStakeRegistered] = useState(false)
const [stakePool, setStakePool] = useState('')
const [poolId, setPoolId] = useState('')

useEffect(() => {
const selectNetwork = async () => {
const walletNetworkId = await api?.getNetworkId()
if (walletNetworkId === 1) {
setNetworkType('mainnet')
setShowNetworkSelection(false)
} else if (walletNetworkId === 0) {
setShowNetworkSelection(true)
}
}
selectNetwork()
}, [api])

const getAccountInfo = async () => {
setWaitingAccountInfo(true)
setErrorMessage('')
setShowSuccessInfo(false)

try {
const rewardAddressHex = firstOrThrow(await api?.getRewardAddresses(), 'No reward address available from wallet')
const delegationInfoResponse = await fetchAccountInfo(networkType, rewardAddressHex)

if (!delegationInfoResponse.ok) {
setErrorMessage('Something went wrong while getting delegation info')
return
}

const delegationInfo = (await delegationInfoResponse.json())[rewardAddressHex]
setStakeRegistered(Boolean(delegationInfo.stakeRegistered))
setStakePool(delegationInfo.delegation || '')
setShowSuccessInfo(true)
} catch (error) {
setErrorMessage('Network error occurred while fetching account info')
logger.error(error)
} finally {
setWaitingAccountInfo(false)
}
}

const delegateClick = async () => {
try {
onWaiting(true)
const registeredPubKeys = (await api?.cip95.getRegisteredPubStakeKeys()) || []
const unregisteredPubKeys = (await api?.cip95.getUnregisteredPubStakeKeys()) || []
const {pubKeyHex, needsRegistration} = resolveDelegationStakeKey({registeredPubKeys, unregisteredPubKeys})
const stakeKeyHash = getStakeKeyHashFromPubKey(pubKeyHex)
const hexUtxos = await api?.getUtxos()
const changeAddressHex = await api?.getChangeAddress()
const tx = buildDelegationTx({
hexUtxos,
changeAddressHex,
stakeKeyHash,
poolId,
registerStakeKey: needsRegistration,
})

const fixedTx = getFixedTxFromBytes(tx.to_bytes())
logger.log('[DelegateCard] Unsigned Tx:', fixedTx)
const signaturesWitnessesSet = await api.signTx(fixedTx.to_hex())
const witnesses = getTransactionWitnessSetFromBytes(signaturesWitnessesSet)
const vkeysSignatures = witnesses.vkeys()
for (let i = 0; i < vkeysSignatures.len(); i++) {
fixedTx.add_vkey_witness(vkeysSignatures.get(i))
}
logger.log('[DelegateCard] Signed Tx:', fixedTx.to_hex())

const txId = await api?.submitTx(fixedTx.to_hex())
onRawResponse(txId)
onResponse(txId, false)
} catch (error) {
logger.error(error)
onRawResponse('')
onResponse(error)
} finally {
onWaiting(false)
}
}

const apiProps = {
buttonLabel: 'Delegate to Pool',
clickFunction: delegateClick,
btnDisabled: poolId.trim().length === 0,
}

return (
<ApiCardWithModal {...apiProps}>
<div className={ModalWindowContent.contentPadding}>
{showNetworkSelection && !waitingAccountInfo && (
<div className="mb-4">
<div className="text-white mb-2">Select Network:</div>
<div className="flex items-center space-x-4 justify-evenly">
<label className="inline-flex items-center">
<input
type="radio"
className="form-radio text-blue-600"
name="delegateNetwork"
value="preprod"
checked={networkType === 'preprod'}
onChange={(e) => {
setNetworkType(e.target.value)
}}
/>
<span className="ml-2 text-white">Preprod</span>
</label>
<label className="inline-flex items-center">
<input
type="radio"
className="form-radio text-blue-600"
name="delegateNetwork"
value="preview"
checked={networkType === 'preview'}
onChange={(e) => {
setNetworkType(e.target.value)
}}
/>
<span className="ml-2 text-white">Preview</span>
</label>
</div>
</div>
)}

{waitingAccountInfo ? (
<div className="flex justify-center items-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
) : (
<>
{errorMessage && <div className="mb-4 p-2 bg-red-900 text-white rounded">{errorMessage}</div>}

{showSuccessInfo && (
<div className="mb-4 p-2 bg-green-900 text-white rounded">
<div>Stake key registered: {stakeRegistered ? 'yes' : 'no'}</div>
<div>Current pool: {stakePool || 'not delegated'}</div>
</div>
)}

<InputWithLabel
inputName="Pool ID"
helpText="bech32 pool1… ID or 56-character hex pool key hash"
inputValue={poolId}
onChangeFunction={(event) => setPoolId(event.target.value)}
/>

<div className="flex">
<div className="flex-auto mt-3 mx-2">
<button
className="w-full py-1 rounded-md text-xl text-white font-semibold bg-green-700 hover:bg-green-800 active:bg-green-500"
onClick={getAccountInfo}
disabled={waitingAccountInfo}
>
Get Account info
</button>
</div>
</div>
</>
)}
</div>
</ApiCardWithModal>
)
}

export default DelegateCard
91 changes: 91 additions & 0 deletions src/components/cards/staking/delegateCard.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {render, screen, fireEvent, waitFor} from '@testing-library/react'
import DelegateCard from './delegateCard'
import {fetchAccountInfo} from './logic/withdraw'

jest.mock('../../../utils/cslTools', () => ({
getFixedTxFromBytes: jest.fn(),
getTransactionWitnessSetFromBytes: jest.fn(),
}))

jest.mock('./logic/delegate', () => ({
buildDelegationTx: jest.fn(),
getStakeKeyHashFromPubKey: jest.fn(),
resolveDelegationStakeKey: jest.fn(),
}))

jest.mock('../apiCardWithModal', () => ({
__esModule: true,
default: ({buttonLabel, clickFunction, btnDisabled, children}) => (
<div>
<span>{buttonLabel}</span>
{children}
<button type="button" disabled={btnDisabled} onClick={clickFunction}>
Send
</button>
</div>
),
}))

jest.mock('./logic/withdraw', () => ({
fetchAccountInfo: jest.fn(),
}))

describe('DelegateCard', () => {
const api = {
getNetworkId: jest.fn().mockResolvedValue(1),
getRewardAddresses: jest.fn().mockResolvedValue(['aabbcc']),
}

beforeEach(() => {
jest.clearAllMocks()
api.getNetworkId.mockResolvedValue(1)
api.getRewardAddresses.mockResolvedValue(['aabbcc'])
})

it('renders the pool ID field and keeps Send disabled until a pool ID is entered', async () => {
render(<DelegateCard api={api} onRawResponse={() => {}} onResponse={() => {}} onWaiting={() => {}} />)
await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled())

expect(screen.getByText('Delegate to Pool')).toBeInTheDocument()
expect(screen.getByLabelText('Pool ID')).toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Send'})).toBeDisabled()

fireEvent.change(screen.getByLabelText('Pool ID'), {
target: {value: 'deadbeef01234567890abcdef01234567890abcdef01234567890abc'},
})
expect(screen.getByRole('button', {name: 'Send'})).not.toBeDisabled()
})

it('shows current pool info after Get Account info succeeds', async () => {
fetchAccountInfo.mockResolvedValue({
ok: true,
json: async () => ({
aabbcc: {stakeRegistered: true, delegation: 'pool1abc'},
}),
})

render(<DelegateCard api={api} onRawResponse={() => {}} onResponse={() => {}} onWaiting={() => {}} />)
await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled())

fireEvent.click(screen.getByRole('button', {name: 'Get Account info'}))

await waitFor(() => {
expect(screen.getByText(/Stake key registered: yes/)).toBeInTheDocument()
})
expect(screen.getByText(/Current pool: pool1abc/)).toBeInTheDocument()
expect(fetchAccountInfo).toHaveBeenCalledWith('mainnet', 'aabbcc')
})

it('shows an error when account info cannot be loaded', async () => {
fetchAccountInfo.mockResolvedValue({ok: false})

render(<DelegateCard api={api} onRawResponse={() => {}} onResponse={() => {}} onWaiting={() => {}} />)
await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled())

fireEvent.click(screen.getByRole('button', {name: 'Get Account info'}))

await waitFor(() => {
expect(screen.getByText(/Something went wrong while getting delegation info/)).toBeInTheDocument()
})
})
})
38 changes: 38 additions & 0 deletions src/components/cards/staking/logic/delegate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
getAddressFromBytes,
getCertificateBuilder,
getCertOfNewStakeDelegation,
getCertOfNewStakeReg,
getCslCredentialFromHex,
getCslUtxos,
getLargestFirstMultiAsset,
getPoolKeyHash,
getPublicKeyFromHex,
getStakeDelegCert,
getStakeKeyRegCert,
getTxBuilder,
} from '../../../../utils/cslTools'
import {resolveDelegationStakeKey} from './stakeKey'

export {resolveDelegationStakeKey}

export const getStakeKeyHashFromPubKey = (pubKeyHex) => getPublicKeyFromHex(pubKeyHex).hash().to_hex()

export const buildDelegationTx = ({hexUtxos, changeAddressHex, stakeKeyHash, poolId, registerStakeKey}) => {
const txBuilder = getTxBuilder()
const certBuilder = getCertificateBuilder()
const stakeCred = getCslCredentialFromHex(stakeKeyHash)

if (registerStakeKey) {
certBuilder.add(getCertOfNewStakeReg(getStakeKeyRegCert(stakeCred)))
}

certBuilder.add(getCertOfNewStakeDelegation(getStakeDelegCert(stakeCred, getPoolKeyHash(poolId))))
txBuilder.set_certs_builder(certBuilder)

const wasmUtxos = getCslUtxos(hexUtxos)
txBuilder.add_inputs_from(wasmUtxos, getLargestFirstMultiAsset())
txBuilder.add_change_if_needed(getAddressFromBytes(changeAddressHex))

return txBuilder.build_tx()
}
9 changes: 9 additions & 0 deletions src/components/cards/staking/logic/stakeKey.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const resolveDelegationStakeKey = ({registeredPubKeys, unregisteredPubKeys}) => {
if (registeredPubKeys?.length) {
return {pubKeyHex: registeredPubKeys[0], needsRegistration: false}
}
if (unregisteredPubKeys?.length) {
return {pubKeyHex: unregisteredPubKeys[0], needsRegistration: true}
}
throw new Error('No stake key available from wallet')
}
23 changes: 23 additions & 0 deletions src/components/cards/staking/logic/stakeKey.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {resolveDelegationStakeKey} from './stakeKey'

describe('resolveDelegationStakeKey', () => {
it('prefers a registered stake key and skips registration', () => {
expect(resolveDelegationStakeKey({registeredPubKeys: ['reg'], unregisteredPubKeys: ['unreg']})).toEqual({
pubKeyHex: 'reg',
needsRegistration: false,
})
})

it('falls back to an unregistered stake key and requires registration', () => {
expect(resolveDelegationStakeKey({registeredPubKeys: [], unregisteredPubKeys: ['unreg']})).toEqual({
pubKeyHex: 'unreg',
needsRegistration: true,
})
})

it('throws when the wallet has no stake keys', () => {
expect(() => resolveDelegationStakeKey({registeredPubKeys: [], unregisteredPubKeys: []})).toThrow(
/No stake key available/,
)
})
})
9 changes: 9 additions & 0 deletions src/components/tabs/subtabs/stakingTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import useCardano from '../../../hooks/cardanoProvider'
import {CONNECTED} from '../../../utils/connectionStates'
import ResponsesPart from './responsesPart'
import WithdrawCard from '../../cards/staking/withdrawCard'
import DelegateCard from '../../cards/staking/delegateCard'

const Staking = () => {
const {api, connectionState} = useCardano()
Expand All @@ -12,6 +13,14 @@ const Staking = () => {
{connectionState === CONNECTED ? (
<div>
<div className="grid justify-items-stretch grid-cols-1 sm:grid-cols-2 gap-2 mb-4">
<div>
<DelegateCard
api={api}
onRawResponse={setRawCurrentText}
onResponse={setResponse}
onWaiting={setWaiterState}
/>
</div>
<div>
<WithdrawCard
api={api}
Expand Down
Loading
Loading