({
+ Tabs: ({children}) => {children}
,
+ TabsHeader: ({children}) => {children}
,
+ Tab: ({children, onClick}) => (
+
+ {children}
+
+ ),
+}))
+
+const Harness = ({tabsData}) => {
+ const [tabs, setTabs] = useState(tabsData)
+ return (
+
+ setTabs(tabs.filter((tab) => tab.value !== 'cip95'))}>
+ Hide CIP-95
+
+
+
+ )
+}
+
+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) =>