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
57 changes: 12 additions & 45 deletions src/components/cards/staking/delegateCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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])
Expand All @@ -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)
Expand Down Expand Up @@ -105,40 +99,6 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => {
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>
Expand All @@ -154,6 +114,13 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => {
</div>
)}

<InputWithLabel
inputName="Blockfrost Project ID"
helpText="project_id from blockfrost.io for this network"
inputValue={projectId}
onChangeFunction={(event) => setProjectId(event.target.value)}
/>

<InputWithLabel
inputName="Pool ID"
helpText="bech32 pool1… ID or 56-character hex pool key hash"
Expand All @@ -166,7 +133,7 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => {
<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}
disabled={waitingAccountInfo || projectId.trim().length === 0}
>
Get Account info
</button>
Expand Down
11 changes: 7 additions & 4 deletions src/components/cards/staking/delegateCard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand All @@ -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(<DelegateCard api={api} onRawResponse={() => {}} 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 () => {
Expand All @@ -82,6 +84,7 @@ describe('DelegateCard', () => {
render(<DelegateCard api={api} onRawResponse={() => {}} 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(() => {
Expand Down
53 changes: 39 additions & 14 deletions src/components/cards/staking/logic/withdraw.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
import {
getBech32AddressFromHex,
getCslCredentialFromHex,
getCslRewardAddress,
getTxBuilder,
getWithdrawalsBuilder,
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) => {
Expand Down
102 changes: 102 additions & 0 deletions src/components/cards/staking/logic/withdraw.test.js
Original file line number Diff line number Diff line change
@@ -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})
})
})
Loading
Loading