diff --git a/src/components/cards/staking/delegateCard.js b/src/components/cards/staking/delegateCard.js index 6798f36..4cce7ee 100644 --- a/src/components/cards/staking/delegateCard.js +++ b/src/components/cards/staking/delegateCard.js @@ -10,7 +10,7 @@ import {buildDelegationTx, getStakeKeyHashFromPubKey, resolveDelegationStakeKey} const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { const [networkType, setNetworkType] = useState('preprod') - const [showNetworkSelection, setShowNetworkSelection] = useState(false) + const [projectId, setProjectId] = useState('') const [waitingAccountInfo, setWaitingAccountInfo] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [showSuccessInfo, setShowSuccessInfo] = useState(false) @@ -21,12 +21,7 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { useEffect(() => { const selectNetwork = async () => { const walletNetworkId = await api?.getNetworkId() - if (walletNetworkId === 1) { - setNetworkType('mainnet') - setShowNetworkSelection(false) - } else if (walletNetworkId === 0) { - setShowNetworkSelection(true) - } + setNetworkType(walletNetworkId === 1 ? 'mainnet' : 'preprod') } selectNetwork() }, [api]) @@ -38,14 +33,13 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { try { const rewardAddressHex = firstOrThrow(await api?.getRewardAddresses(), 'No reward address available from wallet') - const delegationInfoResponse = await fetchAccountInfo(networkType, rewardAddressHex) + const delegationInfo = await fetchAccountInfo(networkType, rewardAddressHex, projectId) - if (!delegationInfoResponse.ok) { + if (!delegationInfo.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) @@ -105,40 +99,6 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { return (
- {showNetworkSelection && !waitingAccountInfo && ( -
-
Select Network:
-
- - -
-
- )} - {waitingAccountInfo ? (
@@ -154,6 +114,13 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => {
)} + setProjectId(event.target.value)} + /> + { diff --git a/src/components/cards/staking/delegateCard.test.js b/src/components/cards/staking/delegateCard.test.js index cecdf5c..1b0e438 100644 --- a/src/components/cards/staking/delegateCard.test.js +++ b/src/components/cards/staking/delegateCard.test.js @@ -48,7 +48,9 @@ describe('DelegateCard', () => { expect(screen.getByText('Delegate to Pool')).toBeInTheDocument() expect(screen.getByLabelText('Pool ID')).toBeInTheDocument() + expect(screen.getByLabelText('Blockfrost Project ID')).toBeInTheDocument() expect(screen.getByRole('button', {name: 'Send'})).toBeDisabled() + expect(screen.getByRole('button', {name: 'Get Account info'})).toBeDisabled() fireEvent.change(screen.getByLabelText('Pool ID'), { target: {value: 'deadbeef01234567890abcdef01234567890abcdef01234567890abc'}, @@ -59,21 +61,21 @@ describe('DelegateCard', () => { it('shows current pool info after Get Account info succeeds', async () => { fetchAccountInfo.mockResolvedValue({ ok: true, - json: async () => ({ - aabbcc: {stakeRegistered: true, delegation: 'pool1abc'}, - }), + stakeRegistered: true, + delegation: 'pool1abc', }) render( {}} onResponse={() => {}} onWaiting={() => {}} />) await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled()) + fireEvent.change(screen.getByLabelText('Blockfrost Project ID'), {target: {value: 'mainnetProjectId'}}) 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') + expect(fetchAccountInfo).toHaveBeenCalledWith('mainnet', 'aabbcc', 'mainnetProjectId') }) it('shows an error when account info cannot be loaded', async () => { @@ -82,6 +84,7 @@ describe('DelegateCard', () => { render( {}} onResponse={() => {}} onWaiting={() => {}} />) await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled()) + fireEvent.change(screen.getByLabelText('Blockfrost Project ID'), {target: {value: 'mainnetProjectId'}}) fireEvent.click(screen.getByRole('button', {name: 'Get Account info'})) await waitFor(() => { diff --git a/src/components/cards/staking/logic/withdraw.js b/src/components/cards/staking/logic/withdraw.js index 0926123..8c3cd6a 100644 --- a/src/components/cards/staking/logic/withdraw.js +++ b/src/components/cards/staking/logic/withdraw.js @@ -1,4 +1,5 @@ import { + getBech32AddressFromHex, getCslCredentialFromHex, getCslRewardAddress, getTxBuilder, @@ -6,25 +7,49 @@ import { strToBigNum, } from '../../../../utils/cslTools' -export const fetchAccountInfo = async (networkType, rewardAddressHex) => { - let backendUrl = '' - if (networkType === 'mainnet') { - backendUrl = 'api.yoroiwallet.com' - } else if (networkType === 'preview') { - backendUrl = 'preview-backend.emurgornd.com' - } else { - backendUrl = 'preprod-backend.yoroiwallet.com' +const BLOCKFROST_BASE_URL = { + mainnet: 'https://cardano-mainnet.blockfrost.io/api/v0', + preprod: 'https://cardano-preprod.blockfrost.io/api/v0', +} + +const unregisteredAccount = () => ({ + ok: true, + stakeRegistered: false, + delegation: '', + remainingAmount: '0', +}) + +export const fetchAccountInfo = async (networkType, rewardAddressHex, projectId) => { + if (!projectId?.trim()) { + return {ok: false} } - const endpointUrl = `https://${backendUrl}/api/account/state` - return await fetch(endpointUrl, { + const baseUrl = BLOCKFROST_BASE_URL[networkType] || BLOCKFROST_BASE_URL.preprod + const stakeAddress = getBech32AddressFromHex(rewardAddressHex) + const endpointUrl = `${baseUrl}/accounts/${stakeAddress}` + + const response = await fetch(endpointUrl, { headers: { - accept: 'application/json, text/plain, */*', - 'content-type': 'application/json', + accept: 'application/json', + project_id: projectId.trim(), }, - body: `{"addresses":["${rewardAddressHex}"]}`, - method: 'POST', }) + + if (response.status === 404) { + return unregisteredAccount() + } + + if (!response.ok) { + return {ok: false} + } + + const data = await response.json() + return { + ok: true, + stakeRegistered: Boolean(data.registered ?? data.active), + delegation: data.pool_id || '', + remainingAmount: data.withdrawable_amount || '0', + } } export const getTxBuilderWithWithdrawal = async (stakeKeyHash, networkType, rewardAmount) => { diff --git a/src/components/cards/staking/logic/withdraw.test.js b/src/components/cards/staking/logic/withdraw.test.js new file mode 100644 index 0000000..8e4b0a0 --- /dev/null +++ b/src/components/cards/staking/logic/withdraw.test.js @@ -0,0 +1,102 @@ +import {fetchAccountInfo} from './withdraw' +import {getBech32AddressFromHex} from '../../../../utils/cslTools' + +jest.mock('../../../../utils/cslTools', () => ({ + getBech32AddressFromHex: jest.fn(), + getCslCredentialFromHex: jest.fn(), + getCslRewardAddress: jest.fn(), + getTxBuilder: jest.fn(), + getWithdrawalsBuilder: jest.fn(), + strToBigNum: jest.fn(), +})) + +describe('fetchAccountInfo', () => { + const stakeBech32 = 'stake1u9example' + const projectId = 'proj_abc' + + beforeEach(() => { + jest.clearAllMocks() + getBech32AddressFromHex.mockReturnValue(stakeBech32) + global.fetch = jest.fn() + }) + + it('does not call Blockfrost when project ID is empty', async () => { + await expect(fetchAccountInfo('mainnet', 'aabbcc', ' ')).resolves.toEqual({ok: false}) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('GETs the mainnet accounts endpoint with project_id', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + registered: true, + active: true, + pool_id: 'pool1abc', + withdrawable_amount: '42', + }), + }) + + await expect(fetchAccountInfo('mainnet', 'aabbcc', ` ${projectId} `)).resolves.toEqual({ + ok: true, + stakeRegistered: true, + delegation: 'pool1abc', + remainingAmount: '42', + }) + + expect(global.fetch).toHaveBeenCalledWith(`https://cardano-mainnet.blockfrost.io/api/v0/accounts/${stakeBech32}`, { + headers: { + accept: 'application/json', + project_id: projectId, + }, + }) + }) + + it('uses the preprod host for non-mainnet networks', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({registered: false, active: false, pool_id: null, withdrawable_amount: '0'}), + }) + + await fetchAccountInfo('preprod', 'aabbcc', projectId) + expect(global.fetch.mock.calls[0][0]).toBe(`https://cardano-preprod.blockfrost.io/api/v0/accounts/${stakeBech32}`) + }) + + it('treats a registered but not delegated account as registered', async () => { + global.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + registered: true, + active: false, + pool_id: null, + withdrawable_amount: '0', + }), + }) + + await expect(fetchAccountInfo('mainnet', 'aabbcc', projectId)).resolves.toEqual({ + ok: true, + stakeRegistered: true, + delegation: '', + remainingAmount: '0', + }) + }) + + it('treats 404 as an unregistered stake key', async () => { + global.fetch.mockResolvedValue({ok: false, status: 404}) + + await expect(fetchAccountInfo('mainnet', 'aabbcc', projectId)).resolves.toEqual({ + ok: true, + stakeRegistered: false, + delegation: '', + remainingAmount: '0', + }) + }) + + it('returns ok: false when Blockfrost rejects the request', async () => { + global.fetch.mockResolvedValue({ok: false, status: 403}) + + await expect(fetchAccountInfo('mainnet', 'aabbcc', projectId)).resolves.toEqual({ok: false}) + }) +}) diff --git a/src/components/cards/staking/withdrawCard.js b/src/components/cards/staking/withdrawCard.js index c6f67b1..1367960 100644 --- a/src/components/cards/staking/withdrawCard.js +++ b/src/components/cards/staking/withdrawCard.js @@ -17,10 +17,11 @@ import {useEffect, useState} from 'react' import {fetchAccountInfo, getTxBuilderWithWithdrawal} from './logic/withdraw' import {firstOrThrow} from '../../../utils/helpFunctions' import CheckboxWithLabel from '../../checkboxWithLabel' +import InputWithLabel from '../../inputWithLabel' const WithdrawCard = ({api, onRawResponse, onResponse, onWaiting}) => { const [networkType, setNetworkType] = useState('preprod') - const [showNetworkSelection, setShowNetworkSelection] = useState(false) + const [projectId, setProjectId] = useState('') const [waitingAccountInfo, setWaitingAccountInfo] = useState(false) const [isDelegated, setIsDelegated] = useState(false) const [rewardAmount, setRewardAmount] = useState('0') @@ -29,19 +30,13 @@ const WithdrawCard = ({api, onRawResponse, onResponse, onWaiting}) => { const [showSuccessInfo, setShowSuccessInfo] = useState(false) const [undelegate, setUndelegate] = useState(false) - const handleNetworkSelection = async () => { - const walletNetworkId = await api?.getNetworkId() - if (walletNetworkId === 1) { - setNetworkType('mainnet') - setShowNetworkSelection(false) - } else if (walletNetworkId === 0) { - setShowNetworkSelection(true) - } - } - useEffect(() => { - handleNetworkSelection() - }) + const selectNetwork = async () => { + const walletNetworkId = await api?.getNetworkId() + setNetworkType(walletNetworkId === 1 ? 'mainnet' : 'preprod') + } + selectNetwork() + }, [api]) const getAccountInfo = async () => { setWaitingAccountInfo(true) @@ -50,15 +45,13 @@ const WithdrawCard = ({api, onRawResponse, onResponse, onWaiting}) => { try { const rewardAddressHex = firstOrThrow(await api?.getRewardAddresses(), 'No reward address available from wallet') - const delegationInfoResponse = await fetchAccountInfo(networkType, rewardAddressHex) + const delegationInfo = await fetchAccountInfo(networkType, rewardAddressHex, projectId) - if (!delegationInfoResponse.ok) { + if (!delegationInfo.ok) { setErrorMessage('Something went wrong while getting delegation info') return } - const delegationInfo = (await delegationInfoResponse.json())[rewardAddressHex] - if (!delegationInfo.stakeRegistered) { setErrorMessage('Staking key is not registered!') return @@ -142,40 +135,6 @@ const WithdrawCard = ({api, onRawResponse, onResponse, onWaiting}) => { return (
- {showNetworkSelection && !waitingAccountInfo && ( -
-
Select Network:
-
- - -
-
- )} - {waitingAccountInfo ? (
@@ -199,12 +158,19 @@ const WithdrawCard = ({api, onRawResponse, onResponse, onWaiting}) => {
)} + setProjectId(event.target.value)} + /> +
diff --git a/src/utils/cslTools.js b/src/utils/cslTools.js index f122250..ba26bdc 100644 --- a/src/utils/cslTools.js +++ b/src/utils/cslTools.js @@ -488,31 +488,19 @@ export const getCertOfNewCommitteeHotAuth = (committeeHotAuthCert) => wasm.Certificate.new_committee_hot_auth(committeeHotAuthCert) export const getCslRewardAddressFromHex = (networkType, rewardAddressHex) => { - switch (networkType) { - case 'mainnet': - return wasm.RewardAddress.new(wasm.NetworkInfo.mainnet().network_id(), wasm.Credential.from_hex(rewardAddressHex)) - case 'preview': - return wasm.RewardAddress.new( - wasm.NetworkInfo.testnet_preview().network_id(), - wasm.Credential.from_hex(rewardAddressHex), - ) - default: - return wasm.RewardAddress.new( - wasm.NetworkInfo.testnet_preprod().network_id(), - wasm.Credential.from_hex(rewardAddressHex), - ) - } + const networkId = + networkType === 'mainnet' + ? wasm.NetworkInfo.mainnet().network_id() + : wasm.NetworkInfo.testnet_preprod().network_id() + return wasm.RewardAddress.new(networkId, wasm.Credential.from_hex(rewardAddressHex)) } export const getCslRewardAddress = (networkType, stakeKeyHashCredential) => { - switch (networkType) { - case 'mainnet': - return wasm.RewardAddress.new(wasm.NetworkInfo.mainnet().network_id(), stakeKeyHashCredential) - case 'preview': - return wasm.RewardAddress.new(wasm.NetworkInfo.testnet_preview().network_id(), stakeKeyHashCredential) - default: - return wasm.RewardAddress.new(wasm.NetworkInfo.testnet_preprod().network_id(), stakeKeyHashCredential) - } + const networkId = + networkType === 'mainnet' + ? wasm.NetworkInfo.mainnet().network_id() + : wasm.NetworkInfo.testnet_preprod().network_id() + return wasm.RewardAddress.new(networkId, stakeKeyHashCredential) } export const getWithdrawalsBuilder = () => wasm.WithdrawalsBuilder.new()