diff --git a/src/components/cards/staking/delegateCard.js b/src/components/cards/staking/delegateCard.js
new file mode 100644
index 0000000..6798f36
--- /dev/null
+++ b/src/components/cards/staking/delegateCard.js
@@ -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 (
+
+
+ {showNetworkSelection && !waitingAccountInfo && (
+
+ )}
+
+ {waitingAccountInfo ? (
+
+ ) : (
+ <>
+ {errorMessage &&
{errorMessage}
}
+
+ {showSuccessInfo && (
+
+
Stake key registered: {stakeRegistered ? 'yes' : 'no'}
+
Current pool: {stakePool || 'not delegated'}
+
+ )}
+
+
setPoolId(event.target.value)}
+ />
+
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+export default DelegateCard
diff --git a/src/components/cards/staking/delegateCard.test.js b/src/components/cards/staking/delegateCard.test.js
new file mode 100644
index 0000000..cecdf5c
--- /dev/null
+++ b/src/components/cards/staking/delegateCard.test.js
@@ -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}) => (
+
+ {buttonLabel}
+ {children}
+
+
+ ),
+}))
+
+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( {}} 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( {}} 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( {}} 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()
+ })
+ })
+})
diff --git a/src/components/cards/staking/logic/delegate.js b/src/components/cards/staking/logic/delegate.js
new file mode 100644
index 0000000..7a0a5e2
--- /dev/null
+++ b/src/components/cards/staking/logic/delegate.js
@@ -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()
+}
diff --git a/src/components/cards/staking/logic/stakeKey.js b/src/components/cards/staking/logic/stakeKey.js
new file mode 100644
index 0000000..294ca24
--- /dev/null
+++ b/src/components/cards/staking/logic/stakeKey.js
@@ -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')
+}
diff --git a/src/components/cards/staking/logic/stakeKey.test.js b/src/components/cards/staking/logic/stakeKey.test.js
new file mode 100644
index 0000000..dee87f6
--- /dev/null
+++ b/src/components/cards/staking/logic/stakeKey.test.js
@@ -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/,
+ )
+ })
+})
diff --git a/src/components/tabs/subtabs/stakingTab.js b/src/components/tabs/subtabs/stakingTab.js
index 3bdff95..b3c36e6 100644
--- a/src/components/tabs/subtabs/stakingTab.js
+++ b/src/components/tabs/subtabs/stakingTab.js
@@ -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()
@@ -12,6 +13,14 @@ const Staking = () => {
{connectionState === CONNECTED ? (
+
+
+
wasm.Int.from_str(numberInStr)
@@ -337,6 +338,12 @@ export const getPublicKeyFromHex = (publicKeyHex) => wasm.PublicKey.from_hex(pub
export const keyHashFromHex = (hexValue) => wasm.Ed25519KeyHash.from_hex(hexValue)
export const keyHashFromBech32 = (bech32Value) => wasm.Ed25519KeyHash.from_bech32(bech32Value)
+
+export const getPoolKeyHash = (poolId) => {
+ const classified = classifyPoolId(poolId)
+ return classified.format === 'hex' ? keyHashFromHex(classified.value) : keyHashFromBech32(classified.value)
+}
+
export const getCslCredentialFromHex = (hexValue) => {
logger.debug('[cslTools][getCslCredentialFromHex]::hexValue', hexValue)
const keyHash = keyHashFromHex(hexValue)
@@ -468,6 +475,12 @@ export const getStakeKeyDeregCert = (stakeCred) => wasm.StakeDeregistration.new(
export const getCertOfNewStakeDereg = (stakeKeyDeregCert) =>
wasm.Certificate.new_stake_deregistration(stakeKeyDeregCert)
+
+// Stake Delegation Certificate
+export const getStakeDelegCert = (stakeCred, poolKeyHash) => wasm.StakeDelegation.new(stakeCred, poolKeyHash)
+
+export const getCertOfNewStakeDelegation = (stakeDelegCert) => wasm.Certificate.new_stake_delegation(stakeDelegCert)
+
// Committee Hot Authorization Certificate
export const getCommitteeHotAuth = (coldCred, hotCred) => wasm.CommitteeHotAuth.new(coldCred, hotCred)