From 1a5fc2a401af39c1fb158a1a4cd7ed7a9679e996 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Mon, 24 Aug 2026 18:26:38 +0300 Subject: [PATCH 1/3] Replace Yoroi backends with Blockfrost for stake account lookup. Delegation and withdrawal now call Blockfrost with a user-supplied project ID, and Preview is no longer a selectable network. Co-authored-by: Cursor --- src/components/cards/staking/delegateCard.js | 57 +++--------- .../cards/staking/delegateCard.test.js | 11 ++- .../cards/staking/logic/withdraw.js | 53 +++++++++--- .../cards/staking/logic/withdraw.test.js | 86 +++++++++++++++++++ src/components/cards/staking/withdrawCard.js | 70 ++++----------- src/utils/cslTools.js | 28 ++---- 6 files changed, 168 insertions(+), 137 deletions(-) create mode 100644 src/components/cards/staking/logic/withdraw.test.js 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..c8e9877 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.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..f5d8107 --- /dev/null +++ b/src/components/cards/staking/logic/withdraw.test.js @@ -0,0 +1,86 @@ +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 () => ({ + 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 () => ({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 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..b61443a 100644 --- a/src/utils/cslTools.js +++ b/src/utils/cslTools.js @@ -488,31 +488,15 @@ 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() From 96c671d53c3207a32dd8da8d72bdc797c8cc87d7 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Tue, 25 Aug 2026 11:15:07 +0300 Subject: [PATCH 2/3] Fix Prettier formatting for Blockfrost account lookup. Co-authored-by: Cursor --- .../cards/staking/logic/withdraw.test.js | 17 ++++++----------- src/utils/cslTools.js | 8 ++++++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/cards/staking/logic/withdraw.test.js b/src/components/cards/staking/logic/withdraw.test.js index f5d8107..73bb37b 100644 --- a/src/components/cards/staking/logic/withdraw.test.js +++ b/src/components/cards/staking/logic/withdraw.test.js @@ -43,15 +43,12 @@ describe('fetchAccountInfo', () => { remainingAmount: '42', }) - expect(global.fetch).toHaveBeenCalledWith( - `https://cardano-mainnet.blockfrost.io/api/v0/accounts/${stakeBech32}`, - { - headers: { - accept: 'application/json', - project_id: projectId, - }, + 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 () => { @@ -62,9 +59,7 @@ describe('fetchAccountInfo', () => { }) await fetchAccountInfo('preprod', 'aabbcc', projectId) - expect(global.fetch.mock.calls[0][0]).toBe( - `https://cardano-preprod.blockfrost.io/api/v0/accounts/${stakeBech32}`, - ) + expect(global.fetch.mock.calls[0][0]).toBe(`https://cardano-preprod.blockfrost.io/api/v0/accounts/${stakeBech32}`) }) it('treats 404 as an unregistered stake key', async () => { diff --git a/src/utils/cslTools.js b/src/utils/cslTools.js index b61443a..ba26bdc 100644 --- a/src/utils/cslTools.js +++ b/src/utils/cslTools.js @@ -489,13 +489,17 @@ export const getCertOfNewCommitteeHotAuth = (committeeHotAuthCert) => export const getCslRewardAddressFromHex = (networkType, rewardAddressHex) => { const networkId = - networkType === 'mainnet' ? wasm.NetworkInfo.mainnet().network_id() : wasm.NetworkInfo.testnet_preprod().network_id() + 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) => { const networkId = - networkType === 'mainnet' ? wasm.NetworkInfo.mainnet().network_id() : wasm.NetworkInfo.testnet_preprod().network_id() + networkType === 'mainnet' + ? wasm.NetworkInfo.mainnet().network_id() + : wasm.NetworkInfo.testnet_preprod().network_id() return wasm.RewardAddress.new(networkId, stakeKeyHashCredential) } From a353f047a84ba093aa6c61227b168c07228b098c Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Tue, 25 Aug 2026 11:24:33 +0300 Subject: [PATCH 3/3] Map stake registration from Blockfrost registered field. active means delegated, so a registered undelegated key was treated as unregistered. Co-authored-by: Cursor --- .../cards/staking/logic/withdraw.js | 2 +- .../cards/staking/logic/withdraw.test.js | 23 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/cards/staking/logic/withdraw.js b/src/components/cards/staking/logic/withdraw.js index c8e9877..8c3cd6a 100644 --- a/src/components/cards/staking/logic/withdraw.js +++ b/src/components/cards/staking/logic/withdraw.js @@ -46,7 +46,7 @@ export const fetchAccountInfo = async (networkType, rewardAddressHex, projectId) const data = await response.json() return { ok: true, - stakeRegistered: Boolean(data.active), + stakeRegistered: Boolean(data.registered ?? data.active), delegation: data.pool_id || '', remainingAmount: data.withdrawable_amount || '0', } diff --git a/src/components/cards/staking/logic/withdraw.test.js b/src/components/cards/staking/logic/withdraw.test.js index 73bb37b..8e4b0a0 100644 --- a/src/components/cards/staking/logic/withdraw.test.js +++ b/src/components/cards/staking/logic/withdraw.test.js @@ -30,6 +30,7 @@ describe('fetchAccountInfo', () => { ok: true, status: 200, json: async () => ({ + registered: true, active: true, pool_id: 'pool1abc', withdrawable_amount: '42', @@ -55,13 +56,33 @@ describe('fetchAccountInfo', () => { global.fetch.mockResolvedValue({ ok: true, status: 200, - json: async () => ({active: false, pool_id: null, withdrawable_amount: '0'}), + 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})