From 6127b01b3affd71c97301d3be19ca4acdc6f6439 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Tue, 25 Aug 2026 12:42:30 +0300 Subject: [PATCH 1/2] feat(cardano): gate CIP-95 and drop it from staking Not every wallet implements CIP-95. Hide those tabs unless the enabled API exposes it, and build delegate/withdraw from CIP-30 reward addresses instead. Co-authored-by: Cursor --- src/App.js | 5 +- src/components/cards/staking/delegateCard.js | 18 ++-- .../cards/staking/delegateCard.test.js | 93 +++++++++++++---- .../cards/staking/logic/delegate.js | 6 -- .../cards/staking/logic/stakeKey.js | 9 -- .../cards/staking/logic/stakeKey.test.js | 23 ----- src/components/cards/staking/withdrawCard.js | 10 +- .../cards/staking/withdrawCard.test.js | 99 +++++++++++++++++++ src/components/tabs/tabsComponent.js | 7 +- src/components/tabs/tabsComponent.test.js | 45 +++++++++ src/hooks/cardanoProvider.js | 15 ++- src/utils/cip95.js | 25 +++++ src/utils/cip95.test.js | 64 ++++++++++++ src/utils/cslTools.js | 14 +++ 14 files changed, 352 insertions(+), 81 deletions(-) delete mode 100644 src/components/cards/staking/logic/stakeKey.js delete mode 100644 src/components/cards/staking/logic/stakeKey.test.js create mode 100644 src/components/cards/staking/withdrawCard.test.js create mode 100644 src/components/tabs/tabsComponent.test.js create mode 100644 src/utils/cip95.js create mode 100644 src/utils/cip95.test.js diff --git a/src/App.js b/src/App.js index 4d1f44a..97df10a 100644 --- a/src/App.js +++ b/src/App.js @@ -8,6 +8,7 @@ import useNetwork, {NETWORK_CARDANO, NETWORK_ETHEREUM} from './hooks/networkProv import BitcoinAccessButton from './components/bitcoinAccessButton' import BitcoinMainTab from './components/tabs/bitcoinMainTab' import {CONNECTED, NO_PROVIDER} from './utils/connectionStates' +import {isCip95Tab} from './utils/cip95' import NetworkToggle from './components/networkToggle' import EthereumAccessButton from './components/ethereumAccessButton' import EthereumMainTab from './components/tabs/ethereumMainTab' @@ -25,7 +26,7 @@ import EthTransactionsTab from './components/tabs/subtabs/ethTransactionsTab' import Erc20Tab from './components/tabs/subtabs/erc20Tab' const App = () => { - const {connectionState, selectedWallet, setConnectionState, setConnectionStateFalse} = useCardano() + const {connectionState, selectedWallet, setConnectionState, setConnectionStateFalse, cip95Available} = useCardano() const {activeNetwork} = useNetwork() const isWalletConnected = connectionState === CONNECTED const isNoProvider = connectionState === NO_PROVIDER @@ -119,7 +120,7 @@ const App = () => { value: 'testTxs', children: , }, - ] + ].filter((tab) => cip95Available || !isCip95Tab(tab.value)) const ethereumTabsData = [ { diff --git a/src/components/cards/staking/delegateCard.js b/src/components/cards/staking/delegateCard.js index 4cce7ee..2025c39 100644 --- a/src/components/cards/staking/delegateCard.js +++ b/src/components/cards/staking/delegateCard.js @@ -1,12 +1,16 @@ import {useEffect, useState} from 'react' import logger from '../../../utils/logger' -import {getFixedTxFromBytes, getTransactionWitnessSetFromBytes} from '../../../utils/cslTools' +import { + getFixedTxFromBytes, + getStakeKeyHashFromRewardAddressHex, + 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' +import {buildDelegationTx} from './logic/delegate' const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { const [networkType, setNetworkType] = useState('preprod') @@ -54,10 +58,8 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { 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 rewardAddressHex = firstOrThrow(await api?.getRewardAddresses(), 'No reward address available from wallet') + const stakeKeyHash = getStakeKeyHashFromRewardAddressHex(rewardAddressHex) const hexUtxos = await api?.getUtxos() const changeAddressHex = await api?.getChangeAddress() const tx = buildDelegationTx({ @@ -65,7 +67,7 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { changeAddressHex, stakeKeyHash, poolId, - registerStakeKey: needsRegistration, + registerStakeKey: !stakeRegistered, }) const fixedTx = getFixedTxFromBytes(tx.to_bytes()) @@ -93,7 +95,7 @@ const DelegateCard = ({api, onRawResponse, onResponse, onWaiting}) => { const apiProps = { buttonLabel: 'Delegate to Pool', clickFunction: delegateClick, - btnDisabled: poolId.trim().length === 0, + btnDisabled: poolId.trim().length === 0 || !showSuccessInfo, } return ( diff --git a/src/components/cards/staking/delegateCard.test.js b/src/components/cards/staking/delegateCard.test.js index 1b0e438..5e214ff 100644 --- a/src/components/cards/staking/delegateCard.test.js +++ b/src/components/cards/staking/delegateCard.test.js @@ -1,16 +1,21 @@ import {render, screen, fireEvent, waitFor} from '@testing-library/react' import DelegateCard from './delegateCard' import {fetchAccountInfo} from './logic/withdraw' +import {buildDelegationTx} from './logic/delegate' +import { + getFixedTxFromBytes, + getStakeKeyHashFromRewardAddressHex, + getTransactionWitnessSetFromBytes, +} from '../../../utils/cslTools' jest.mock('../../../utils/cslTools', () => ({ getFixedTxFromBytes: jest.fn(), getTransactionWitnessSetFromBytes: jest.fn(), + getStakeKeyHashFromRewardAddressHex: jest.fn(), })) jest.mock('./logic/delegate', () => ({ buildDelegationTx: jest.fn(), - getStakeKeyHashFromPubKey: jest.fn(), - resolveDelegationStakeKey: jest.fn(), })) jest.mock('../apiCardWithModal', () => ({ @@ -34,15 +39,55 @@ describe('DelegateCard', () => { const api = { getNetworkId: jest.fn().mockResolvedValue(1), getRewardAddresses: jest.fn().mockResolvedValue(['aabbcc']), + getUtxos: jest.fn().mockResolvedValue(['utxo']), + getChangeAddress: jest.fn().mockResolvedValue('change'), + signTx: jest.fn().mockResolvedValue('sig-hex'), + submitTx: jest.fn().mockResolvedValue('tx-id'), + cip95: { + getRegisteredPubStakeKeys: jest.fn(), + getUnregisteredPubStakeKeys: jest.fn(), + }, + } + + const fillPoolId = () => { + fireEvent.change(screen.getByLabelText('Pool ID'), { + target: {value: 'deadbeef01234567890abcdef01234567890abcdef01234567890abc'}, + }) + } + + const loadAccountInfo = async () => { + 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() + }) } beforeEach(() => { jest.clearAllMocks() api.getNetworkId.mockResolvedValue(1) api.getRewardAddresses.mockResolvedValue(['aabbcc']) + api.getUtxos.mockResolvedValue(['utxo']) + api.getChangeAddress.mockResolvedValue('change') + api.signTx.mockResolvedValue('sig-hex') + api.submitTx.mockResolvedValue('tx-id') + fetchAccountInfo.mockResolvedValue({ + ok: true, + stakeRegistered: true, + delegation: 'pool1abc', + }) + getStakeKeyHashFromRewardAddressHex.mockReturnValue('stake-key-hash') + buildDelegationTx.mockReturnValue({to_bytes: () => new Uint8Array([1])}) + getFixedTxFromBytes.mockReturnValue({ + to_hex: () => 'signed-tx', + add_vkey_witness: jest.fn(), + }) + getTransactionWitnessSetFromBytes.mockReturnValue({ + vkeys: () => ({len: () => 0, get: () => null}), + }) }) - it('renders the pool ID field and keeps Send disabled until a pool ID is entered', async () => { + it('renders the pool ID field and keeps Send disabled until account info is loaded', async () => { render( {}} onResponse={() => {}} onWaiting={() => {}} />) await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled()) @@ -52,28 +97,15 @@ describe('DelegateCard', () => { 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'}, - }) - expect(screen.getByRole('button', {name: 'Send'})).not.toBeDisabled() + fillPoolId() + expect(screen.getByRole('button', {name: 'Send'})).toBeDisabled() }) it('shows current pool info after Get Account info succeeds', async () => { - fetchAccountInfo.mockResolvedValue({ - ok: true, - 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() - }) + await loadAccountInfo() expect(screen.getByText(/Current pool: pool1abc/)).toBeInTheDocument() expect(fetchAccountInfo).toHaveBeenCalledWith('mainnet', 'aabbcc', 'mainnetProjectId') }) @@ -91,4 +123,27 @@ describe('DelegateCard', () => { expect(screen.getByText(/Something went wrong while getting delegation info/)).toBeInTheDocument() }) }) + + it('builds the delegation tx from the CIP-30 reward address, without CIP-95', async () => { + const onRawResponse = jest.fn() + const onResponse = jest.fn() + render( {}} />) + await waitFor(() => expect(api.getNetworkId).toHaveBeenCalled()) + + fillPoolId() + await loadAccountInfo() + fireEvent.click(screen.getByRole('button', {name: 'Send'})) + + await waitFor(() => expect(api.submitTx).toHaveBeenCalled()) + expect(api.cip95.getRegisteredPubStakeKeys).not.toHaveBeenCalled() + expect(api.cip95.getUnregisteredPubStakeKeys).not.toHaveBeenCalled() + expect(getStakeKeyHashFromRewardAddressHex).toHaveBeenCalledWith('aabbcc') + expect(buildDelegationTx).toHaveBeenCalledWith( + expect.objectContaining({ + stakeKeyHash: 'stake-key-hash', + registerStakeKey: false, + }), + ) + expect(onRawResponse).toHaveBeenCalledWith('tx-id') + }) }) diff --git a/src/components/cards/staking/logic/delegate.js b/src/components/cards/staking/logic/delegate.js index 7a0a5e2..10b5b25 100644 --- a/src/components/cards/staking/logic/delegate.js +++ b/src/components/cards/staking/logic/delegate.js @@ -7,16 +7,10 @@ import { 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() diff --git a/src/components/cards/staking/logic/stakeKey.js b/src/components/cards/staking/logic/stakeKey.js deleted file mode 100644 index 294ca24..0000000 --- a/src/components/cards/staking/logic/stakeKey.js +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index dee87f6..0000000 --- a/src/components/cards/staking/logic/stakeKey.test.js +++ /dev/null @@ -1,23 +0,0 @@ -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/cards/staking/withdrawCard.js b/src/components/cards/staking/withdrawCard.js index 1367960..d923f38 100644 --- a/src/components/cards/staking/withdrawCard.js +++ b/src/components/cards/staking/withdrawCard.js @@ -7,8 +7,8 @@ import { getCslUtxos, getLargestFirstMultiAsset, getFixedTxFromBytes, - getPublicKeyFromHex, getStakeKeyDeregCert, + getStakeKeyHashFromRewardAddressHex, getTransactionWitnessSetFromBytes, } from '../../../utils/cslTools' import ApiCardWithModal from '../apiCardWithModal' @@ -79,12 +79,8 @@ const WithdrawCard = ({api, onRawResponse, onResponse, onWaiting}) => { try { onWaiting(true) // build withdraw - const pubStakeKey = await api?.cip95.getRegisteredPubStakeKeys() - const stakeKeyHash = getPublicKeyFromHex( - firstOrThrow(pubStakeKey, 'No registered stake key available from wallet'), - ) - .hash() - .to_hex() + const rewardAddressHex = firstOrThrow(await api?.getRewardAddresses(), 'No reward address available from wallet') + const stakeKeyHash = getStakeKeyHashFromRewardAddressHex(rewardAddressHex) const txBuilderWithWithdrawal = await getTxBuilderWithWithdrawal(stakeKeyHash, networkType, rewardAmount) const utxos = await api?.getUtxos() diff --git a/src/components/cards/staking/withdrawCard.test.js b/src/components/cards/staking/withdrawCard.test.js new file mode 100644 index 0000000..73fe096 --- /dev/null +++ b/src/components/cards/staking/withdrawCard.test.js @@ -0,0 +1,99 @@ +import {render, screen, fireEvent, waitFor} from '@testing-library/react' +import WithdrawCard from './withdrawCard' +import {fetchAccountInfo, getTxBuilderWithWithdrawal} from './logic/withdraw' +import { + getFixedTxFromBytes, + getStakeKeyHashFromRewardAddressHex, + getTransactionWitnessSetFromBytes, +} from '../../../utils/cslTools' + +jest.mock('../../../utils/cslTools', () => ({ + getAddressFromBytes: jest.fn(), + getCertificateBuilder: jest.fn(), + getCertOfNewStakeDereg: jest.fn(), + getCslCredentialFromHex: jest.fn(), + getCslUtxos: jest.fn(), + getLargestFirstMultiAsset: jest.fn(), + getFixedTxFromBytes: jest.fn(), + getStakeKeyDeregCert: jest.fn(), + getStakeKeyHashFromRewardAddressHex: jest.fn(), + getTransactionWitnessSetFromBytes: jest.fn(), +})) + +jest.mock('../apiCardWithModal', () => ({ + __esModule: true, + default: ({buttonLabel, clickFunction, btnDisabled, children}) => ( +
+ {buttonLabel} + {children} + +
+ ), +})) + +jest.mock('./logic/withdraw', () => ({ + fetchAccountInfo: jest.fn(), + getTxBuilderWithWithdrawal: jest.fn(), +})) + +describe('WithdrawCard', () => { + const api = { + getNetworkId: jest.fn().mockResolvedValue(1), + getRewardAddresses: jest.fn().mockResolvedValue(['aabbcc']), + getUtxos: jest.fn().mockResolvedValue(['utxo']), + getChangeAddress: jest.fn().mockResolvedValue('change'), + signTx: jest.fn().mockResolvedValue('sig-hex'), + submitTx: jest.fn().mockResolvedValue('tx-id'), + cip95: { + getRegisteredPubStakeKeys: jest.fn(), + }, + } + + beforeEach(() => { + jest.clearAllMocks() + api.getNetworkId.mockResolvedValue(1) + api.getRewardAddresses.mockResolvedValue(['aabbcc']) + api.getUtxos.mockResolvedValue(['utxo']) + api.getChangeAddress.mockResolvedValue('change') + api.signTx.mockResolvedValue('sig-hex') + api.submitTx.mockResolvedValue('tx-id') + fetchAccountInfo.mockResolvedValue({ + ok: true, + stakeRegistered: true, + delegation: 'pool1abc', + remainingAmount: '1000', + }) + getStakeKeyHashFromRewardAddressHex.mockReturnValue('stake-key-hash') + getFixedTxFromBytes.mockReturnValue({ + to_hex: () => 'signed-tx', + add_vkey_witness: jest.fn(), + }) + getTransactionWitnessSetFromBytes.mockReturnValue({ + vkeys: () => ({len: () => 0, get: () => null}), + }) + getTxBuilderWithWithdrawal.mockResolvedValue({ + set_certs_builder: jest.fn(), + add_inputs_from: jest.fn(), + add_change_if_needed: jest.fn(), + build_tx: jest.fn(() => ({to_bytes: () => new Uint8Array([1])})), + }) + }) + + it('withdraws using the CIP-30 reward address, without CIP-95', async () => { + 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'})) + expect(await screen.findByText(/Available reward/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', {name: 'Send'})) + await waitFor(() => expect(api.submitTx).toHaveBeenCalled()) + + expect(api.cip95.getRegisteredPubStakeKeys).not.toHaveBeenCalled() + expect(getStakeKeyHashFromRewardAddressHex).toHaveBeenCalledWith('aabbcc') + expect(getTxBuilderWithWithdrawal).toHaveBeenCalledWith('stake-key-hash', 'mainnet', '1000') + }) +}) diff --git a/src/components/tabs/tabsComponent.js b/src/components/tabs/tabsComponent.js index 330f8bf..e613835 100644 --- a/src/components/tabs/tabsComponent.js +++ b/src/components/tabs/tabsComponent.js @@ -3,17 +3,18 @@ import {Tabs, TabsHeader, Tab} from '@material-tailwind/react' const TabsComponent = ({tabsData}) => { const [activeTab, setActiveTab] = useState(tabsData[0].value) - const activeTabData = tabsData.find((tab) => tab.value === activeTab) + const safeActiveTab = tabsData.some((tab) => tab.value === activeTab) ? activeTab : tabsData[0]?.value + const activeTabData = tabsData.find((tab) => tab.value === safeActiveTab) return ( - + {tabsData.map(({label, value}) => ( ({ + Tabs: ({children}) =>
{children}
, + TabsHeader: ({children}) =>
{children}
, + Tab: ({children, onClick}) => ( + + ), +})) + +const Harness = ({tabsData}) => { + const [tabs, setTabs] = useState(tabsData) + return ( +
+ + +
+ ) +} + +describe('TabsComponent', () => { + it('falls back to the first remaining tab when the active tab is removed', () => { + render( + cip30 panel}, + {label: 'CIP-95', value: 'cip95', children:
cip95 panel
}, + ]} + />, + ) + + fireEvent.click(screen.getByRole('button', {name: 'CIP-95'})) + expect(screen.getByText('cip95 panel')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', {name: 'Hide CIP-95'})) + expect(screen.queryByText('cip95 panel')).not.toBeInTheDocument() + expect(screen.getByText('cip30 panel')).toBeInTheDocument() + }) +}) diff --git a/src/hooks/cardanoProvider.js b/src/hooks/cardanoProvider.js index 76b55d8..f2ff096 100644 --- a/src/hooks/cardanoProvider.js +++ b/src/hooks/cardanoProvider.js @@ -2,6 +2,7 @@ import logger from '../utils/logger' import React, {useState, useEffect, useCallback, useMemo} from 'react' import useToast from './toastProvider' import useConnectionState from './useConnectionState' +import {buildEnableOptions, isCip95Api} from '../utils/cip95' const CardanoContext = React.createContext(null) const reservedKeys = [ @@ -32,10 +33,12 @@ export const CardanoProvider = ({children}) => { const [api, setApi] = useState(null) const [availableWallets, setAvailableWallets] = useState([]) const [selectedWallet, setSelectedWallet] = useState('') + const [cip95Available, setCip95Available] = useState(false) const setConnectionStateFalse = useCallback(() => { setNotConnected() setApi(null) + setCip95Available(false) }, [setNotConnected]) const getAvailableWallets = () => { @@ -61,6 +64,7 @@ export const CardanoProvider = ({children}) => { async (walletName, requestIdentification, silent, throwError = false) => { setInProgress() setApi(null) + setCip95Available(false) logger.debug(`[dApp][connect] is called`) if (!window.cardano) { @@ -73,18 +77,18 @@ export const CardanoProvider = ({children}) => { logger.debug(`[dApp][connect] {requestIdentification: ${requestIdentification}, onlySilent: ${silent}}`) try { - const connectedApi = await window.cardano[walletName].enable({ - requestIdentification, - onlySilent: silent, - }) + const wallet = window.cardano[walletName] + const connectedApi = await wallet.enable(buildEnableOptions({requestIdentification, silent, wallet})) logger.debug(`[dApp][connect] wallet API object is received`) setApi(connectedApi) + setCip95Available(isCip95Api(connectedApi)) setSelectedWallet(walletName) setConnected() return connectedApi } catch (error) { logger.error(`[dApp][connect] The error received while connecting the wallet`) setSelectedWallet('') + setCip95Available(false) setNotConnected() // Surface user-initiated connection failures; stay quiet on the silent // background reconnect so page load doesn't pop a toast. @@ -163,6 +167,7 @@ export const CardanoProvider = ({children}) => { const disconnect = useCallback(() => { setApi(null) setSelectedWallet('') + setCip95Available(false) setNotConnected() }, [setNotConnected]) @@ -206,6 +211,7 @@ export const CardanoProvider = ({children}) => { availableWallets, setAvailableWallets, selectedWallet, + cip95Available, setConnectionState, setConnectionStateFalse, setSelectedWallet, @@ -221,6 +227,7 @@ export const CardanoProvider = ({children}) => { connectionState, availableWallets, selectedWallet, + cip95Available, setConnectionState, setConnectionStateFalse, ], diff --git a/src/utils/cip95.js b/src/utils/cip95.js new file mode 100644 index 0000000..309f2b0 --- /dev/null +++ b/src/utils/cip95.js @@ -0,0 +1,25 @@ +export const CIP95 = 95 + +export const walletSupportsCip95 = (wallet) => { + if (!Array.isArray(wallet?.supportedExtensions)) { + return false + } + return wallet.supportedExtensions.some((extension) => Number(extension?.cip) === CIP95) +} + +export const buildEnableOptions = ({requestIdentification, silent, wallet}) => { + const options = { + requestIdentification, + onlySilent: silent, + } + if (walletSupportsCip95(wallet)) { + options.extensions = [{cip: CIP95}] + } + return options +} + +export const isCip95Api = (api) => Boolean(api?.cip95) + +export const CIP95_TAB_VALUES = ['cip95', 'cip95Tools'] + +export const isCip95Tab = (tabValue) => CIP95_TAB_VALUES.includes(tabValue) diff --git a/src/utils/cip95.test.js b/src/utils/cip95.test.js new file mode 100644 index 0000000..a958e7f --- /dev/null +++ b/src/utils/cip95.test.js @@ -0,0 +1,64 @@ +import {buildEnableOptions, isCip95Api, isCip95Tab, walletSupportsCip95} from './cip95' + +describe('walletSupportsCip95', () => { + it('returns false when supportedExtensions is missing', () => { + expect(walletSupportsCip95({})).toBe(false) + expect(walletSupportsCip95(undefined)).toBe(false) + }) + + it('returns false when CIP-95 is not listed', () => { + expect(walletSupportsCip95({supportedExtensions: [{cip: 30}]})).toBe(false) + expect(walletSupportsCip95({supportedExtensions: []})).toBe(false) + }) + + it('returns true when CIP-95 is listed as a number or numeric string', () => { + expect(walletSupportsCip95({supportedExtensions: [{cip: 95}]})).toBe(true) + expect(walletSupportsCip95({supportedExtensions: [{cip: '95'}]})).toBe(true) + }) +}) + +describe('buildEnableOptions', () => { + it('omits extensions when the wallet does not advertise CIP-95', () => { + expect( + buildEnableOptions({ + requestIdentification: true, + silent: false, + wallet: {supportedExtensions: [{cip: 30}]}, + }), + ).toEqual({ + requestIdentification: true, + onlySilent: false, + }) + }) + + it('requests CIP-95 only when the wallet advertises it', () => { + expect( + buildEnableOptions({ + requestIdentification: false, + silent: true, + wallet: {supportedExtensions: [{cip: 95}]}, + }), + ).toEqual({ + requestIdentification: false, + onlySilent: true, + extensions: [{cip: 95}], + }) + }) +}) + +describe('isCip95Api', () => { + it('is true only when the enabled API exposes cip95', () => { + expect(isCip95Api({cip95: {}})).toBe(true) + expect(isCip95Api({})).toBe(false) + expect(isCip95Api(null)).toBe(false) + }) +}) + +describe('isCip95Tab', () => { + it('marks CIP-95 UI tabs and nothing else', () => { + expect(isCip95Tab('cip95')).toBe(true) + expect(isCip95Tab('cip95Tools')).toBe(true) + expect(isCip95Tab('staking')).toBe(false) + expect(isCip95Tab('cip30')).toBe(false) + }) +}) diff --git a/src/utils/cslTools.js b/src/utils/cslTools.js index ba26bdc..98d6f99 100644 --- a/src/utils/cslTools.js +++ b/src/utils/cslTools.js @@ -212,6 +212,20 @@ export const getTransactionWitnessSetFromBytes = (witnessHex) => export const getPubKeyHash = (usedAddress) => wasm.BaseAddress.from_address(usedAddress).payment_cred().to_keyhash() +// CIP-30 getRewardAddresses() returns a stake (reward) address. Certificates and +// withdrawals need the stake key hash from that address — not CIP-95 pub keys. +export const getStakeKeyHashFromRewardAddressHex = (rewardAddressHex) => { + const rewardAddr = wasm.RewardAddress.from_address(getAddressFromBytes(rewardAddressHex)) + if (!rewardAddr) { + throw new Error('Wallet did not return a reward address') + } + const keyHash = rewardAddr.payment_cred().to_keyhash() + if (!keyHash) { + throw new Error('Reward address does not contain a stake key hash') + } + return keyHash.to_hex() +} + export const getNativeScript = (pubKeyHash) => wasm.NativeScript.new_script_pubkey(wasm.ScriptPubkey.new(pubKeyHash)) export const getTransactionOutputBuilder = (wasmChangeAddress) => From 59d72504e469bcb787ab73057f77f512519f5fb2 Mon Sep 17 00:00:00 2001 From: Denis Nebytov Date: Tue, 25 Aug 2026 13:45:40 +0300 Subject: [PATCH 2/2] chore: bump version to 2.6.0 Co-authored-by: Cursor --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61a3466..59a0a72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dapp-example", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dapp-example", - "version": "2.5.0", + "version": "2.6.0", "dependencies": { "@emurgo/cardano-serialization-lib-browser": "14.1.2", "@emurgo/cip4-js": "^1.0.7", diff --git a/package.json b/package.json index 34d9c09..ad83384 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dapp-example", - "version": "2.5.0", + "version": "2.6.0", "private": true, "dependencies": { "@emurgo/cardano-serialization-lib-browser": "14.1.2",