From 40f05cf0a3b743046fb1c65e1467ad8ca7fdbd05 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Mon, 24 Aug 2026 13:25:11 +0300 Subject: [PATCH] Add stake delegation with optional key registration. Build a Delegate card that resolves a CIP-95 stake key, attaches a pool-id certificate, and registers the key when the wallet has none yet. Co-authored-by: Cursor --- src/components/cards/staking/delegateCard.js | 182 ++++++++++++++++++ .../cards/staking/delegateCard.test.js | 91 +++++++++ .../cards/staking/logic/delegate.js | 38 ++++ .../cards/staking/logic/stakeKey.js | 9 + .../cards/staking/logic/stakeKey.test.js | 23 +++ src/components/tabs/subtabs/stakingTab.js | 9 + src/utils/cslTools.js | 13 ++ 7 files changed, 365 insertions(+) create mode 100644 src/components/cards/staking/delegateCard.js create mode 100644 src/components/cards/staking/delegateCard.test.js create mode 100644 src/components/cards/staking/logic/delegate.js create mode 100644 src/components/cards/staking/logic/stakeKey.js create mode 100644 src/components/cards/staking/logic/stakeKey.test.js 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 && ( +
+
Select Network:
+
+ + +
+
+ )} + + {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) @@ -255,6 +256,12 @@ export const keyHashFromHex = (hexValue) => wasm.Ed25519KeyHash.from_hex(hexValu 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) @@ -387,6 +394,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)