diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c4645b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy to `.env` to override defaults. Only variables prefixed with REACT_APP_ +# are exposed to the app (Create React App convention). + +# Enable verbose debug/log output in production builds. +# - During local development (`npm start`) logs are ON by default regardless. +# - In production builds logs are OFF unless this is set to "true". +# - You can also toggle logs at runtime from the browser console, without +# rebuilding: dappLogs.on() / dappLogs.off() / dappLogs.toggle(). +REACT_APP_DEBUG=false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5509669..bce689b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,10 @@ jobs: run: | npm i + - name: Create production .env + run: | + echo "REACT_APP_DEBUG=false" > .env + - name: Build run: | npm run build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f309098 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Read .nvmrc + id: nvm + run: echo "version=$(cat .nvmrc)" >> $GITHUB_OUTPUT + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: '${{ steps.nvm.outputs.version }}' + cache: 'npm' + + - name: Install dependencies + run: npm i + + - name: Check formatting + run: npm run format:check + + - name: Lint + run: npm run lint + + - name: Run tests + run: npm run test:ci diff --git a/.prettierrc b/.prettierrc index 833bd64..99fa213 100644 --- a/.prettierrc +++ b/.prettierrc @@ -7,8 +7,5 @@ "semi": false, "singleQuote": true, "bracketSpacing": false, - "arrowParens": "always", - "importOrder": ["^@(.*)$", "^[./]" ], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true + "arrowParens": "always" } diff --git a/README.md b/README.md index e5be600..ab6f694 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,90 @@ # dapp-example -The project is created with purpose to show how to work with yoroi dApp-connector. +A simple, multi-chain example dApp that shows how to interact with wallet +connectors — built for clarity, learning, testing, and extension. -It may not look good but it works and can be used as an example to other projects how to interact with the dApp-connector. +Live demo: https://dapp-example.yoroiwallet.com/ -You can find the dApp on this link https://dapp-example.yoroiwallet.com/ +> It may not look fancy, but it works and is meant as a reference for other +> projects on how to talk to wallet dApp-connectors. + +## Supported chains + +- **Cardano** (CIP-30) — live, via browser wallet extensions (e.g. Yoroi) +- **Ethereum** (EIP-1193) — live, via `window.ethereum` +- **Bitcoin** — planned (provider is currently a stub) + +## Tech stack + +- React 18 (functional components + hooks), JavaScript only (no TypeScript) +- Tailwind CSS + Material Tailwind +- Create React App + craco +- Cardano serialization via `@emurgo/cardano-serialization-lib-browser` + +## Getting started + +Prerequisites: Node.js (see [`.nvmrc`](./.nvmrc)) and npm. + +```bash +npm install # install dependencies +npm start # run the dev server at http://localhost:3000 +npm run build # production build into ./build +``` + +A Cardano and/or Ethereum browser wallet extension is required to exercise the +connect flows. + +## Available scripts + +| Script | Description | +| --- | --- | +| `npm start` | Start the development server | +| `npm run build` | Production build | +| `npm test` | Run tests in watch mode | +| `npm run test:ci` | Run tests once (CI mode) | +| `npm run lint` | Lint `src` with ESLint | +| `npm run lint:fix` | Lint and auto-fix | +| `npm run format` | Format `src` with Prettier | +| `npm run format:check` | Check formatting without writing | + +## Project structure + +``` +src/ + hooks/ # Providers (one per chain) + network toggle + shared hooks + components/ # UI: access buttons, tabs, cards, shared inputs + utils/ # Helpers and blockchain utilities (cslTools, ethereumUtils, ...) +``` + +Each chain follows the same flow: **Provider → Access Button → Main Tab → +Subtabs → Cards**. See [`CLAUDE.md`](./CLAUDE.md) for the full architecture and +conventions. + +## Logging + +The app uses a small debug-gated logger (`src/utils/logger.js`): + +- `debug` / `log` / `info` print only in development, or in a production build + started with `REACT_APP_DEBUG=true` (see [`.env.example`](./.env.example)). +- `warn` / `error` always print. + +You can toggle logs live from the browser console — no rebuild needed: + +```js +dappLogs.on() // enable and remember across reloads +dappLogs.off() // disable and remember across reloads +dappLogs.toggle() // flip current state +dappLogs.status() // -> true | false +dappLogs.reset() // forget the override, fall back to the build default +``` + +## Testing + +Tests use Jest + React Testing Library (via CRA). Run `npm run test:ci` for a +single pass, or `npm test` to watch. Tests and linting run in CI on pushes to +`main` and on pull requests. + +## Configuration + +Copy [`.env.example`](./.env.example) to `.env` to override defaults. Only +variables prefixed with `REACT_APP_` are exposed to the app. diff --git a/package-lock.json b/package-lock.json index a99e8c5..0e6f5fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dapp-example", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dapp-example", - "version": "2.3.0", + "version": "2.4.0", "dependencies": { "@emurgo/cardano-serialization-lib-browser": "14.1.2", "@emurgo/cip4-js": "^1.0.7", @@ -35,6 +35,7 @@ "@rollup/plugin-terser": "^0.4.4", "autoprefixer": "^10.4.8", "postcss": "^8.4.16", + "prettier": "^3.9.4", "tailwindcss": "^3.1.8" } }, @@ -18713,6 +18714,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/package.json b/package.json index c453dda..847b080 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dapp-example", - "version": "2.3.0", + "version": "2.4.0", "private": true, "dependencies": { "@emurgo/cardano-serialization-lib-browser": "14.1.2", @@ -30,12 +30,18 @@ "@rollup/plugin-terser": "^0.4.4", "autoprefixer": "^10.4.8", "postcss": "^8.4.16", + "prettier": "^3.9.4", "tailwindcss": "^3.1.8" }, "scripts": { "start": "craco start", "build": "CI=false && craco build", "test": "craco test", + "test:ci": "CI=true craco test", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "format": "prettier --write \"src/**/*.js\"", + "format:check": "prettier --check \"src/**/*.js\"", "eject": "craco eject" }, "eslintConfig": { diff --git a/src/App.js b/src/App.js index d0720c9..4d1f44a 100644 --- a/src/App.js +++ b/src/App.js @@ -1,8 +1,9 @@ +import logger from './utils/logger' import React, {useEffect} from 'react' import AccessButton from './components/accessButton' import MainTab from './components/tabs/mainTab' import TabsComponent from './components/tabs/tabsComponent' -import useYoroi from './hooks/yoroiProvider' +import useCardano from './hooks/cardanoProvider' import useNetwork, {NETWORK_CARDANO, NETWORK_ETHEREUM} from './hooks/networkProvider' import BitcoinAccessButton from './components/bitcoinAccessButton' import BitcoinMainTab from './components/tabs/bitcoinMainTab' @@ -24,7 +25,7 @@ import EthTransactionsTab from './components/tabs/subtabs/ethTransactionsTab' import Erc20Tab from './components/tabs/subtabs/erc20Tab' const App = () => { - const {connectionState, selectedWallet, setConnectionState, setConnectionStateFalse} = useYoroi() + const {connectionState, selectedWallet, setConnectionState, setConnectionStateFalse} = useCardano() const {activeNetwork} = useNetwork() const isWalletConnected = connectionState === CONNECTED const isNoProvider = connectionState === NO_PROVIDER @@ -42,7 +43,7 @@ const App = () => { useEffect(() => { const getConnectionState = async () => { - console.debug(`[dApp][App] Checking connection works`) + logger.debug(`[dApp][App] Checking connection works`) try { const walletObject = window.cardano[selectedWallet] const conState = await walletStateWithTimeout(walletObject, 10000) @@ -54,14 +55,14 @@ const App = () => { } } catch (error) { setConnectionStateFalse() - console.error(error) + logger.error(error) } } if (isWalletConnected) { const connectionTimer = setInterval(getConnectionState, 10000) return () => { - console.debug(`[dApp][App] Checking connection is stopped`) + logger.debug(`[dApp][App] Checking connection is stopped`) clearInterval(connectionTimer) } } diff --git a/src/components/accessButton.js b/src/components/accessButton.js index 27559fa..cc6be1c 100644 --- a/src/components/accessButton.js +++ b/src/components/accessButton.js @@ -1,11 +1,13 @@ +import logger from '../utils/logger' import React from 'react' -import useYoroi from '../hooks/yoroiProvider' +import useCardano from '../hooks/cardanoProvider' import {IN_PROGRESS} from '../utils/connectionStates' import WalletsModal from './walletsModal' +import AccessButtonShell from './accessButtonShell' const AccessButton = () => { - const {api, connectionState, availableWallets, selectedWallet} = useYoroi() - console.log(`[dApp][AccessButton] available wallets: ${availableWallets.length}`) + const {api, connectionState, availableWallets, selectedWallet} = useCardano() + logger.log(`[dApp][AccessButton] available wallets: ${availableWallets.length}`) const getWalletIcon = () => { return window.cardano[selectedWallet].icon @@ -19,27 +21,25 @@ const AccessButton = () => { } return ( -
-
- {api ? ( -
- wallet icon -
-
Connected To {getWalletName()}
-
anonymous wallet
-
+ + {api ? ( +
+ wallet icon +
+
Connected To {getWalletName()}
+
anonymous wallet
- ) : connectionState === IN_PROGRESS ? ( -
- -
- ) : ( -
- -
- )} -
-
+
+ ) : connectionState === IN_PROGRESS ? ( +
+ +
+ ) : ( +
+ +
+ )} + ) } diff --git a/src/components/accessButtonShell.js b/src/components/accessButtonShell.js new file mode 100644 index 0000000..b115e11 --- /dev/null +++ b/src/components/accessButtonShell.js @@ -0,0 +1,10 @@ +import React from 'react' + +// Shared chrome for each chain's access button: dark background + centered grid. +const AccessButtonShell = ({children, innerClassName = 'grid justify-items-center py-3'}) => ( +
+
{children}
+
+) + +export default AccessButtonShell diff --git a/src/components/bitcoinAccessButton.js b/src/components/bitcoinAccessButton.js index 7dc02fb..9b162a2 100644 --- a/src/components/bitcoinAccessButton.js +++ b/src/components/bitcoinAccessButton.js @@ -1,18 +1,14 @@ -import React from 'react' +import AccessButtonShell from './accessButtonShell' -const BitcoinAccessButton = () => { - return ( -
-
- -
-
- ) -} +const BitcoinAccessButton = () => ( + + + +) export default BitcoinAccessButton diff --git a/src/components/cards/apiCard.js b/src/components/cards/apiCard.js index 7d522ed..73a5d8b 100644 --- a/src/components/cards/apiCard.js +++ b/src/components/cards/apiCard.js @@ -1,5 +1,13 @@ import React from 'react' +// Static map so Tailwind's JIT keeps these classes — `h-${height}` would be +// built at runtime and purged from the production build. +const HEIGHT_CLASSES = { + 10: 'h-10', + 16: 'h-16', + 24: 'h-24', +} + const ApiCard = (props) => { const {apiName, clickFunction, color, height} = props @@ -7,7 +15,7 @@ const ApiCard = (props) => { if (color != null) { localColor = color } - const localHeight = height == null ? 'h-16' : `h-${height}` + const localHeight = HEIGHT_CLASSES[height] || 'h-16' const localClassName = `w-full ${localHeight} ${localColor} disabled:bg-gray-800 rounded-lg text-white text-lg` diff --git a/src/components/cards/apiCard.test.js b/src/components/cards/apiCard.test.js new file mode 100644 index 0000000..4a1aee4 --- /dev/null +++ b/src/components/cards/apiCard.test.js @@ -0,0 +1,28 @@ +import {render, screen, fireEvent} from '@testing-library/react' +import ApiCard from './apiCard' + +describe('ApiCard', () => { + it('renders the apiName as a button and fires clickFunction', () => { + const onClick = jest.fn() + render() + + const button = screen.getByRole('button', {name: 'Do Thing'}) + fireEvent.click(button) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('applies a mapped height class', () => { + render( {}} height={24} />) + expect(screen.getByRole('button', {name: 'Tall'}).className).toContain('h-24') + }) + + it('falls back to h-16 for an unmapped height', () => { + render( {}} height={999} />) + expect(screen.getByRole('button', {name: 'Default'}).className).toContain('h-16') + }) + + it('uses the provided color class when given', () => { + render( {}} color="bg-red-700" />) + expect(screen.getByRole('button', {name: 'Red'}).className).toContain('bg-red-700') + }) +}) diff --git a/src/components/cards/apiCardWithModal.js b/src/components/cards/apiCardWithModal.js index 0c0cf43..519c7b2 100644 --- a/src/components/cards/apiCardWithModal.js +++ b/src/components/cards/apiCardWithModal.js @@ -1,13 +1,14 @@ +import logger from '../../utils/logger' import Popup from 'reactjs-popup' export const ApiCardWithModal = (props) => { const {buttonLabel, clickFunction, halfOpacity, children, btnDisabled} = props const handleActionAndClose = (closeFunc) => { - console.log(`[dApp][ApiCardWithModal][${buttonLabel}] is called`) + logger.log(`[dApp][ApiCardWithModal][${buttonLabel}] is called`) clickFunction() closeFunc() - console.log(`[dApp][ApiCardWithModal][${buttonLabel}] is closed`) + logger.log(`[dApp][ApiCardWithModal][${buttonLabel}] is closed`) } const overlayStyle = {background: 'rgba(0,0,0,0.75)'} @@ -29,8 +30,8 @@ export const ApiCardWithModal = (props) => { } return ( - {buttonLabel}} + {buttonLabel}} {...{modal, nested, overlayStyle}} contentStyle={contentStyle} > @@ -53,16 +54,14 @@ export const ApiCardWithModal = (props) => {
{/* content section */} -
- {children} -
+
{children}
{/* end of content section*/} {/* confirmation button */}
-
-
+ + + ) } diff --git a/src/components/inputWithLabel.js b/src/components/inputWithLabel.js index ffdf776..e2ef2aa 100644 --- a/src/components/inputWithLabel.js +++ b/src/components/inputWithLabel.js @@ -2,23 +2,28 @@ import React from 'react' import {CommonStyles, ModalWindowContent} from './ui-constants' const InputWithLabel = (props) => { - const {inputName, inputValue, onChangeFunction, helpText, type} = props + const {inputName, inputValue, onChangeFunction, helpText, type, placeholder, min, step, wrapperClassName, disabled} = + props const inputType = type || 'text' const inputID = inputName.split(' ').join('') return ( -
+
{helpText ? (
+ ))} +
+ ) +} + +export default ToastContainer diff --git a/src/components/walletsModal.js b/src/components/walletsModal.js index 2560c80..8d1116b 100644 --- a/src/components/walletsModal.js +++ b/src/components/walletsModal.js @@ -1,18 +1,19 @@ +import logger from '../utils/logger' import React, {useState} from 'react' import Popup from 'reactjs-popup' -import useYoroi from '../hooks/yoroiProvider' +import useCardano from '../hooks/cardanoProvider' import {NO_PROVIDER} from '../utils/connectionStates' const WalletsModal = () => { - const {connect, availableWallets, setSelectedWallet, connectionState} = useYoroi() + const {connect, availableWallets, setSelectedWallet, connectionState} = useCardano() const [selectedUserWallet, setSelectedUserWallet] = useState('') - console.log(`[dApp][WalletsModal] is called`) + logger.log(`[dApp][WalletsModal] is called`) const handleSelectionAndClose = (closeFunc) => { - console.log(`[dApp][WalletsModal] selected wallet is ${selectedUserWallet}`) + logger.log(`[dApp][WalletsModal] selected wallet is ${selectedUserWallet}`) setSelectedWallet(selectedUserWallet) closeFunc() - console.log(`[dApp][WalletsModal] is closed`) + logger.log(`[dApp][WalletsModal] is closed`) connect(selectedUserWallet, false, false) } diff --git a/src/hooks/bitcoinProvider.js b/src/hooks/bitcoinProvider.js index 38e8e58..f9da30b 100644 --- a/src/hooks/bitcoinProvider.js +++ b/src/hooks/bitcoinProvider.js @@ -1,37 +1,39 @@ -import React, {useState} from 'react' -import {NO_PROVIDER} from '../utils/connectionStates' +import logger from '../utils/logger' +import React from 'react' +import useConnectionState from './useConnectionState' const BitcoinContext = React.createContext(null) export const BitcoinProvider = ({children}) => { - console.debug('[dApp][BitcoinProvider] is called') - const [connectionState] = useState(NO_PROVIDER) + logger.debug('[dApp][BitcoinProvider] is called') + // Stub provider — stays NO_PROVIDER until Bitcoin support is implemented. + const {connectionState} = useConnectionState() const connect = async () => { - console.warn('[dApp][BitcoinProvider] connect: not implemented') + logger.warn('[dApp][BitcoinProvider] connect: not implemented') } const disconnect = () => { - console.warn('[dApp][BitcoinProvider] disconnect: not implemented') + logger.warn('[dApp][BitcoinProvider] disconnect: not implemented') } const getAccounts = async () => { - console.warn('[dApp][BitcoinProvider] getAccounts: not implemented') + logger.warn('[dApp][BitcoinProvider] getAccounts: not implemented') return [] } const getBalance = async (_address) => { - console.warn('[dApp][BitcoinProvider] getBalance: not implemented') + logger.warn('[dApp][BitcoinProvider] getBalance: not implemented') return '0' } const sendTransaction = async (_tx) => { - console.warn('[dApp][BitcoinProvider] sendTransaction: not implemented') + logger.warn('[dApp][BitcoinProvider] sendTransaction: not implemented') throw new Error('Bitcoin sendTransaction not implemented') } const signMessage = async (_message) => { - console.warn('[dApp][BitcoinProvider] signMessage: not implemented') + logger.warn('[dApp][BitcoinProvider] signMessage: not implemented') throw new Error('Bitcoin signMessage not implemented') } @@ -50,7 +52,7 @@ export const BitcoinProvider = ({children}) => { const useBitcoin = () => { const context = React.useContext(BitcoinContext) - if (context === undefined) throw new Error('useBitcoin must be used within BitcoinProvider') + if (!context) throw new Error('useBitcoin must be used within BitcoinProvider') return context } diff --git a/src/hooks/cardanoProvider.js b/src/hooks/cardanoProvider.js new file mode 100644 index 0000000..76b55d8 --- /dev/null +++ b/src/hooks/cardanoProvider.js @@ -0,0 +1,242 @@ +import logger from '../utils/logger' +import React, {useState, useEffect, useCallback, useMemo} from 'react' +import useToast from './toastProvider' +import useConnectionState from './useConnectionState' + +const CardanoContext = React.createContext(null) +const reservedKeys = [ + 'enable', + 'isEnabled', + 'getBalance', + 'signData', + 'signTx', + 'submitTx', + 'getUtxos', + 'getCollateral', + 'getUsedAddresses', + 'getUnusedAddresses', + 'getChangeAddress', + 'getRewardAddress', + 'getNetworkId', + 'onAccountChange', + 'onNetworkChange', + 'off', + '_events', +] + +export const CardanoProvider = ({children}) => { + logger.debug('[dApp][CardanoProvider] is called') + const {showToast} = useToast() + const {connectionState, setConnectionState, setConnected, setNotConnected, setInProgress, setNoProvider} = + useConnectionState() + const [api, setApi] = useState(null) + const [availableWallets, setAvailableWallets] = useState([]) + const [selectedWallet, setSelectedWallet] = useState('') + + const setConnectionStateFalse = useCallback(() => { + setNotConnected() + setApi(null) + }, [setNotConnected]) + + const getAvailableWallets = () => { + // We need to filter like this because of the Nami wallet. + // It injects everything into the cardano object not only the object "nami". + const userWallets = Object.keys(window.cardano).filter((cardanoKey) => !reservedKeys.includes(cardanoKey)) + return userWallets.map((walletName) => { + return { + walletObjKey: walletName, + walletObjInfo: window.cardano[walletName], + } + }) + } + + /** + * @param {string} walletName - A wallet name as it is presented in the Cardano object + * @param {boolean} requestIdentification - Request connection with or without required authentication + * @param {boolean} silent - Request connection with or without showing the connection pop-up + * @param {boolean} throwError - Throw an error which possibly can be while connecting to the wallet + * @returns {Promise} + */ + const connect = useCallback( + async (walletName, requestIdentification, silent, throwError = false) => { + setInProgress() + setApi(null) + logger.debug(`[dApp][connect] is called`) + + if (!window.cardano) { + logger.error('There are no cardano wallets are installed') + setNotConnected() + return + } + + logger.log(`[dApp][connect] connecting the wallet "${walletName}"`) + logger.debug(`[dApp][connect] {requestIdentification: ${requestIdentification}, onlySilent: ${silent}}`) + + try { + const connectedApi = await window.cardano[walletName].enable({ + requestIdentification, + onlySilent: silent, + }) + logger.debug(`[dApp][connect] wallet API object is received`) + setApi(connectedApi) + setSelectedWallet(walletName) + setConnected() + return connectedApi + } catch (error) { + logger.error(`[dApp][connect] The error received while connecting the wallet`) + setSelectedWallet('') + setNotConnected() + // Surface user-initiated connection failures; stay quiet on the silent + // background reconnect so page load doesn't pop a toast. + if (!silent) { + showToast( + `Failed to connect wallet "${walletName}": ${error?.info ?? error?.message ?? JSON.stringify(error)}`, + ) + } + if (throwError) { + throw new Error(JSON.stringify(error)) + } else { + logger.error(`[dApp][connect] ${JSON.stringify(error)}`) + } + } + }, + [showToast, setInProgress, setNotConnected, setConnected], + ) + + useEffect(() => { + if (!window.cardano) { + logger.warn('[dApp] There are no cardano wallets are installed') + setNoProvider() + return + } + + /** + * @param {string} walletName - A wallet name as it is presented in the Cardano object + * @returns {Promise} + */ + const tryConnectSilent = async (walletName) => { + let connectResult = null + logger.debug(`[dApp][tryConnectSilent] is called`) + try { + logger.debug(`[dApp][tryConnectSilent] trying {false, true}`) + setInProgress() + connectResult = await connect(walletName, false, true, false) + if (connectResult != null) { + logger.log('[dApp][tryConnectSilent] RE-CONNECTED!') + setSelectedWallet(walletName) + setConnected() + return + } + } catch (error) { + setSelectedWallet('') + setNotConnected() + logger.error(error) + } + } + + const availableWallets = getAvailableWallets() + logger.log('[dApp] allInfoWallets: ', availableWallets) + setAvailableWallets(availableWallets) + + if (availableWallets.length === 1) { + const existingWallet = availableWallets[0].walletObjKey + const walletObject = window.cardano[existingWallet] + walletObject + .isEnabled() + .then((response) => { + logger.debug(`[dApp] Connection is enabled: ${response}`) + if (response) { + tryConnectSilent(existingWallet).then() + } else { + setNotConnected() + } + }) + .catch((err) => { + setNotConnected() + logger.error(err) + }) + } else { + setNotConnected() + } + }, [connect, setInProgress, setConnected, setNotConnected, setNoProvider]) + + const disconnect = useCallback(() => { + setApi(null) + setSelectedWallet('') + setNotConnected() + }, [setNotConnected]) + + const getAccounts = useCallback(async () => { + if (!api) return [] + return await api.getUsedAddresses() + }, [api]) + + const getBalance = useCallback(async () => { + if (!api) return '0' + return await api.getBalance() + }, [api]) + + const sendTransaction = useCallback( + async (tx) => { + if (!api) throw new Error('Not connected') + const signedTx = await api.signTx(tx) + return await api.submitTx(signedTx) + }, + [api], + ) + + const signMessage = useCallback( + async (address, payload) => { + if (!api) throw new Error('Not connected') + return await api.signData(address, payload) + }, + [api], + ) + + const values = useMemo( + () => ({ + api, + connect, + disconnect, + getAccounts, + getBalance, + sendTransaction, + signMessage, + connectionState, + availableWallets, + setAvailableWallets, + selectedWallet, + setConnectionState, + setConnectionStateFalse, + setSelectedWallet, + }), + [ + api, + connect, + disconnect, + getAccounts, + getBalance, + sendTransaction, + signMessage, + connectionState, + availableWallets, + selectedWallet, + setConnectionState, + setConnectionStateFalse, + ], + ) + + return {children} +} + +const useCardano = () => { + const context = React.useContext(CardanoContext) + + if (!context) { + throw new Error('useCardano must be used within CardanoProvider') + } + + return context +} + +export default useCardano diff --git a/src/hooks/chainProvider.js b/src/hooks/chainProvider.js new file mode 100644 index 0000000..e6bc812 --- /dev/null +++ b/src/hooks/chainProvider.js @@ -0,0 +1,18 @@ +// The shared contract every chain provider implements. Each provider (Cardano, +// Ethereum, Bitcoin) exposes at least this shape through its React context; +// chain-specific extras (Cardano's selectedWallet/availableWallets, Ethereum's +// chainId, ...) are layered on top. See CLAUDE.md "Multi-Chain Abstraction". +// +// Connection state is driven by the shared useConnectionState() machine and +// uses the values from utils/connectionStates.js. +// +// @typedef {Object} ChainProvider +// @property {string} connectionState NOT_CONNECTED | IN_PROGRESS | CONNECTED | NO_PROVIDER +// @property {(...args: any[]) => Promise} connect +// @property {() => void} disconnect +// @property {() => (string[] | Promise)} getAccounts +// @property {(address?: string) => Promise} getBalance +// @property {(tx: any) => Promise} sendTransaction +// @property {(...args: any[]) => Promise} signMessage + +export {} diff --git a/src/hooks/ethereumProvider.js b/src/hooks/ethereumProvider.js index f959d0b..51c288b 100644 --- a/src/hooks/ethereumProvider.js +++ b/src/hooks/ethereumProvider.js @@ -1,35 +1,38 @@ +import logger from '../utils/logger' import React, {useState, useEffect, useCallback, useMemo} from 'react' -import {NOT_CONNECTED, IN_PROGRESS, CONNECTED, NO_PROVIDER} from '../utils/connectionStates' +import useToast from './toastProvider' +import useConnectionState from './useConnectionState' const EthereumContext = React.createContext(null) export const EthereumProvider = ({children}) => { - console.debug('[dApp][EthereumProvider] is called') + logger.debug('[dApp][EthereumProvider] is called') + const {showToast} = useToast() + const {connectionState, setConnected, setNotConnected, setInProgress, setNoProvider} = useConnectionState() const [accounts, setAccounts] = useState([]) - const [connectionState, setConnectionState] = useState(NO_PROVIDER) const [chainId, setChainId] = useState(null) useEffect(() => { if (!window.ethereum) { - console.warn('[dApp] No Ethereum wallet found') - setConnectionState(NO_PROVIDER) + logger.warn('[dApp] No Ethereum wallet found') + setNoProvider() return } - setConnectionState(NOT_CONNECTED) + setNotConnected() const handleAccountsChanged = (newAccounts) => { - console.debug('[dApp][EthereumProvider] accountsChanged', newAccounts) + logger.debug('[dApp][EthereumProvider] accountsChanged', newAccounts) if (newAccounts.length === 0) { - setConnectionState(NOT_CONNECTED) + setNotConnected() setAccounts([]) } else { setAccounts(newAccounts) - setConnectionState(CONNECTED) + setConnected() } } const handleChainChanged = (newChainId) => { - console.debug('[dApp][EthereumProvider] chainChanged', newChainId) + logger.debug('[dApp][EthereumProvider] chainChanged', newChainId) setChainId(newChainId) } @@ -40,29 +43,30 @@ export const EthereumProvider = ({children}) => { window.ethereum.removeListener('accountsChanged', handleAccountsChanged) window.ethereum.removeListener('chainChanged', handleChainChanged) } - }, []) + }, [setNoProvider, setNotConnected, setConnected]) const connect = useCallback(async () => { if (!window.ethereum) return - setConnectionState(IN_PROGRESS) - console.debug('[dApp][EthereumProvider] connect is called') + setInProgress() + logger.debug('[dApp][EthereumProvider] connect is called') try { const accs = await window.ethereum.request({method: 'eth_requestAccounts'}) const chain = await window.ethereum.request({method: 'eth_chainId'}) setAccounts(accs) setChainId(chain) - setConnectionState(CONNECTED) - console.log('[dApp][EthereumProvider] CONNECTED, accounts:', accs) + setConnected() + logger.log('[dApp][EthereumProvider] CONNECTED, accounts:', accs) } catch (err) { - console.error('[dApp][EthereumProvider] connect error', err) - setConnectionState(NOT_CONNECTED) + logger.error('[dApp][EthereumProvider] connect error', err) + setNotConnected() + showToast(`Failed to connect Ethereum wallet: ${err?.message ?? JSON.stringify(err)}`) } - }, []) + }, [showToast, setInProgress, setConnected, setNotConnected]) const disconnect = useCallback(() => { setAccounts([]) - setConnectionState(NOT_CONNECTED) - }, []) + setNotConnected() + }, [setNotConnected]) const getAccounts = useCallback(() => accounts, [accounts]) @@ -76,29 +80,35 @@ export const EthereumProvider = ({children}) => { return await window.ethereum.request({method: 'eth_sendTransaction', params: [tx]}) }, []) - const signMessage = useCallback(async (message) => { - if (!window.ethereum || accounts.length === 0) throw new Error('Not connected') - return await window.ethereum.request({method: 'personal_sign', params: [message, accounts[0]]}) - }, [accounts]) - - const values = useMemo(() => ({ - accounts, - connectionState, - chainId, - connect, - disconnect, - getAccounts, - getBalance, - sendTransaction, - signMessage, - }), [accounts, connectionState, chainId, connect, disconnect, getAccounts, getBalance, sendTransaction, signMessage]) + const signMessage = useCallback( + async (message) => { + if (!window.ethereum || accounts.length === 0) throw new Error('Not connected') + return await window.ethereum.request({method: 'personal_sign', params: [message, accounts[0]]}) + }, + [accounts], + ) + + const values = useMemo( + () => ({ + accounts, + connectionState, + chainId, + connect, + disconnect, + getAccounts, + getBalance, + sendTransaction, + signMessage, + }), + [accounts, connectionState, chainId, connect, disconnect, getAccounts, getBalance, sendTransaction, signMessage], + ) return {children} } const useEthereum = () => { const context = React.useContext(EthereumContext) - if (context === undefined) throw new Error('useEthereum must be used within EthereumProvider') + if (!context) throw new Error('useEthereum must be used within EthereumProvider') return context } diff --git a/src/hooks/toastProvider.js b/src/hooks/toastProvider.js new file mode 100644 index 0000000..a0d836a --- /dev/null +++ b/src/hooks/toastProvider.js @@ -0,0 +1,47 @@ +import React, {useState, useRef, useCallback, useMemo} from 'react' +import ToastContainer from '../components/toastContainer' + +const ToastContext = React.createContext(null) + +const AUTO_DISMISS_MS = 6000 + +// App-wide toast notifications. Any component can call `useToast().showToast(...)` +// to surface a message (errors, info) as a visible banner instead of a +// console-only log. +export const ToastProvider = ({children}) => { + const [toasts, setToasts] = useState([]) + const nextId = useRef(0) + + const dismissToast = useCallback((id) => { + setToasts((current) => current.filter((toast) => toast.id !== id)) + }, []) + + const showToast = useCallback( + (message, type = 'error') => { + const id = ++nextId.current + setToasts((current) => [...current, {id, message, type}]) + setTimeout(() => dismissToast(id), AUTO_DISMISS_MS) + return id + }, + [dismissToast], + ) + + const value = useMemo(() => ({showToast, dismissToast}), [showToast, dismissToast]) + + return ( + + {children} + + + ) +} + +const useToast = () => { + const context = React.useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within ToastProvider') + } + return context +} + +export default useToast diff --git a/src/hooks/toastProvider.test.js b/src/hooks/toastProvider.test.js new file mode 100644 index 0000000..d72460c --- /dev/null +++ b/src/hooks/toastProvider.test.js @@ -0,0 +1,54 @@ +import {render, screen, fireEvent, act} from '@testing-library/react' +import useToast, {ToastProvider} from './toastProvider' + +const Trigger = () => { + const {showToast} = useToast() + return +} + +describe('ToastProvider', () => { + it('shows a toast and dismisses it via the close button', () => { + render( + + + , + ) + + fireEvent.click(screen.getByText('go')) + expect(screen.getByRole('alert')).toHaveTextContent('Boom happened') + + fireEvent.click(screen.getByLabelText('Dismiss')) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('auto-dismisses after the timeout', () => { + jest.useFakeTimers() + try { + render( + + + , + ) + + fireEvent.click(screen.getByText('go')) + expect(screen.getByRole('alert')).toBeInTheDocument() + + act(() => { + jest.advanceTimersByTime(6000) + }) + expect(screen.queryByRole('alert')).toBeNull() + } finally { + jest.useRealTimers() + } + }) + + it('throws when useToast is used outside the provider', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const Bad = () => { + useToast() + return null + } + expect(() => render()).toThrow('useToast must be used within ToastProvider') + spy.mockRestore() + }) +}) diff --git a/src/hooks/useConnectionState.js b/src/hooks/useConnectionState.js new file mode 100644 index 0000000..cf8c016 --- /dev/null +++ b/src/hooks/useConnectionState.js @@ -0,0 +1,19 @@ +import {useState, useCallback} from 'react' +import {NOT_CONNECTED, IN_PROGRESS, CONNECTED, NO_PROVIDER} from '../utils/connectionStates' + +// Shared connection-state machine used by every chain provider so the +// NOT_CONNECTED / IN_PROGRESS / CONNECTED / NO_PROVIDER transitions stay +// consistent across chains. Returns the raw setter too, for the rare caller +// that needs it (e.g. App's reconnect polling via the Cardano context). +const useConnectionState = (initial = NO_PROVIDER) => { + const [connectionState, setConnectionState] = useState(initial) + + const setConnected = useCallback(() => setConnectionState(CONNECTED), []) + const setNotConnected = useCallback(() => setConnectionState(NOT_CONNECTED), []) + const setInProgress = useCallback(() => setConnectionState(IN_PROGRESS), []) + const setNoProvider = useCallback(() => setConnectionState(NO_PROVIDER), []) + + return {connectionState, setConnectionState, setConnected, setNotConnected, setInProgress, setNoProvider} +} + +export default useConnectionState diff --git a/src/hooks/useConnectionState.test.js b/src/hooks/useConnectionState.test.js new file mode 100644 index 0000000..c607a47 --- /dev/null +++ b/src/hooks/useConnectionState.test.js @@ -0,0 +1,38 @@ +import {renderHook, act} from '@testing-library/react' +import useConnectionState from './useConnectionState' +import {CONNECTED, IN_PROGRESS, NOT_CONNECTED, NO_PROVIDER} from '../utils/connectionStates' + +describe('useConnectionState', () => { + it('defaults to NO_PROVIDER', () => { + const {result} = renderHook(() => useConnectionState()) + expect(result.current.connectionState).toBe(NO_PROVIDER) + }) + + it('honors a custom initial state', () => { + const {result} = renderHook(() => useConnectionState(NOT_CONNECTED)) + expect(result.current.connectionState).toBe(NOT_CONNECTED) + }) + + it('transitions through the named setters', () => { + const {result} = renderHook(() => useConnectionState()) + + act(() => result.current.setInProgress()) + expect(result.current.connectionState).toBe(IN_PROGRESS) + + act(() => result.current.setConnected()) + expect(result.current.connectionState).toBe(CONNECTED) + + act(() => result.current.setNotConnected()) + expect(result.current.connectionState).toBe(NOT_CONNECTED) + + act(() => result.current.setNoProvider()) + expect(result.current.connectionState).toBe(NO_PROVIDER) + }) + + it('keeps setter identities stable across renders', () => { + const {result, rerender} = renderHook(() => useConnectionState()) + const firstSetConnected = result.current.setConnected + rerender() + expect(result.current.setConnected).toBe(firstSetConnected) + }) +}) diff --git a/src/hooks/useResponseState.js b/src/hooks/useResponseState.js new file mode 100644 index 0000000..6f606ce --- /dev/null +++ b/src/hooks/useResponseState.js @@ -0,0 +1,18 @@ +import {useState, useCallback} from 'react' + +// Shared response state used by every subtab: the "Part" components write results +// through setResponse / setRawCurrentText / setWaiterState, and ResponsesPart +// renders currentText / rawCurrentText / waiterState. +const useResponseState = () => { + const [currentText, setCurrentText] = useState('') + const [rawCurrentText, setRawCurrentText] = useState('') + const [waiterState, setWaiterState] = useState(false) + + const setResponse = useCallback((response, stringifyIt = true) => { + setCurrentText(stringifyIt ? JSON.stringify(response, undefined, 2) : response) + }, []) + + return {currentText, rawCurrentText, waiterState, setRawCurrentText, setWaiterState, setResponse} +} + +export default useResponseState diff --git a/src/hooks/yoroiProvider.js b/src/hooks/yoroiProvider.js deleted file mode 100644 index 9a1caeb..0000000 --- a/src/hooks/yoroiProvider.js +++ /dev/null @@ -1,206 +0,0 @@ -import React, {useState, useEffect} from 'react' -import {NOT_CONNECTED, IN_PROGRESS, CONNECTED, NO_PROVIDER} from '../utils/connectionStates' - -const YoroiContext = React.createContext(null) -const reservedKeys = [ - 'enable', - 'isEnabled', - 'getBalance', - 'signData', - 'signTx', - 'submitTx', - 'getUtxos', - 'getCollateral', - 'getUsedAddresses', - 'getUnusedAddresses', - 'getChangeAddress', - 'getRewardAddress', - 'getNetworkId', - 'onAccountChange', - 'onNetworkChange', - 'off', - '_events', -] - -export const YoroiProvider = ({children}) => { - console.debug('[dApp][YoroiProvider] is called') - const [api, setApi] = useState(null) - const [connectionState, setConnectionState] = useState(NO_PROVIDER) - const [availableWallets, setAvailableWallets] = useState([]) - const [selectedWallet, setSelectedWallet] = useState('') - - const setConnectionStateFalse = () => { - setConnectionState(NOT_CONNECTED) - setApi(null) - } - - const getAvailableWallets = () => { - // We need to filter like this because of the Nami wallet. - // It injects everything into the cardano object not only the object "nami". - const userWallets = Object.keys(window.cardano).filter((cardanoKey) => !reservedKeys.includes(cardanoKey)) - return userWallets.map((walletName) => { - return { - walletObjKey: walletName, - walletObjInfo: window.cardano[walletName], - } - }) - } - - useEffect(() => { - if (!window.cardano) { - console.warn('[dApp] There are no cardano wallets are installed') - setConnectionState(NO_PROVIDER) - return - } - - /** - * @param {string} walletName - A wallet name as it is presented in the Cardano object - * @returns {Promise} - */ - const tryConnectSilent = async (walletName) => { - let connectResult = null - console.debug(`[dApp][tryConnectSilent] is called`) - try { - console.debug(`[dApp][tryConnectSilent] trying {false, true}`) - setConnectionState(IN_PROGRESS) - connectResult = await connect(walletName, false, true, false) - if (connectResult != null) { - console.log('[dApp][tryConnectSilent] RE-CONNECTED!') - setSelectedWallet(walletName) - setConnectionState(CONNECTED) - return - } - } catch (error) { - setSelectedWallet('') - setConnectionState(NOT_CONNECTED) - console.error(error) - } - } - - const availableWallets = getAvailableWallets() - console.log('[dApp] allInfoWallets: ', availableWallets) - setAvailableWallets(availableWallets) - - if (availableWallets.length === 1) { - const existingWallet = availableWallets[0].walletObjKey - const walletObject = window.cardano[existingWallet] - walletObject - .isEnabled() - .then((response) => { - console.debug(`[dApp] Connection is enabled: ${response}`) - if (response) { - tryConnectSilent(existingWallet).then() - } else { - setConnectionState(NOT_CONNECTED) - } - }) - .catch((err) => { - setConnectionState(NOT_CONNECTED) - console.error(err) - }) - } else { - setConnectionState(NOT_CONNECTED); - } - }, []) - - /** - * @param {string} walletName - A wallet name as it is presented in the Cardano object - * @param {bool} requestId - Request connection with or without required authentication - * @param {bool} silent - Request connection with or without showing the connection pop-up - * @param {bool} throwError - Throw an error which possibly can be while connecting to the wallet - * @returns {Promise} - */ - const connect = async (walletName, requestId, silent, throwError = false) => { - setConnectionState(IN_PROGRESS) - setApi(null) - console.debug(`[dApp][connect] is called`) - - if (!window.cardano) { - console.error('There are no cardano wallets are installed') - setConnectionState(NOT_CONNECTED) - return - } - - console.log(`[dApp][connect] connecting the wallet "${walletName}"`) - console.debug(`[dApp][connect] {requestIdentification: ${requestId}, onlySilent: ${silent}}`) - - try { - const connectedApi = await window.cardano[walletName].enable({ - requestIdentification: requestId, - onlySilent: silent, - }) - console.debug(`[dApp][connect] wallet API object is received`) - setApi(connectedApi) - setSelectedWallet(walletName) - setConnectionState(CONNECTED) - return connectedApi - } catch (error) { - console.error(`[dApp][connect] The error received while connecting the wallet`) - setSelectedWallet('') - setConnectionState(NOT_CONNECTED) - if (throwError) { - throw new Error(JSON.stringify(error)) - } else { - console.error(`[dApp][connect] ${JSON.stringify(error)}`) - } - } - } - - const disconnect = () => { - setApi(null) - setSelectedWallet('') - setConnectionState(NOT_CONNECTED) - } - - const getAccounts = async () => { - if (!api) return [] - return await api.getUsedAddresses() - } - - const getBalance = async () => { - if (!api) return '0' - return await api.getBalance() - } - - const sendTransaction = async (tx) => { - if (!api) throw new Error('Not connected') - const signedTx = await api.signTx(tx) - return await api.submitTx(signedTx) - } - - const signMessage = async (address, payload) => { - if (!api) throw new Error('Not connected') - return await api.signData(address, payload) - } - - const values = { - api, - connect, - disconnect, - getAccounts, - getBalance, - sendTransaction, - signMessage, - connectionState, - availableWallets, - setAvailableWallets, - selectedWallet, - setConnectionState, - setConnectionStateFalse, - setSelectedWallet, - } - - return {children} -} - -const useYoroi = () => { - const context = React.useContext(YoroiContext) - - if (context === undefined) { - throw new Error('Install Yoroi') - } - - return context -} - -export default useYoroi diff --git a/src/index.js b/src/index.js index 372e1de..4898019 100644 --- a/src/index.js +++ b/src/index.js @@ -2,27 +2,30 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './index.css' import App from './App' -import {YoroiProvider} from './hooks/yoroiProvider' +import {CardanoProvider} from './hooks/cardanoProvider' import {NetworkProvider} from './hooks/networkProvider' import {EthereumProvider} from './hooks/ethereumProvider' import {BitcoinProvider} from './hooks/bitcoinProvider' +import {ToastProvider} from './hooks/toastProvider' import {BrowserRouter, Route, Routes} from 'react-router-dom' const root = ReactDOM.createRoot(document.getElementById('root')) root.render( - - - - - - - } /> - - - - - - + + + + + + + + } /> + + + + + + + , ) diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 0000000..fe64dfa --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1,3 @@ +// Adds custom jest matchers such as toBeInTheDocument(). +// Automatically loaded by Create React App / craco before each test file. +import '@testing-library/jest-dom' diff --git a/src/utils/buildCert.js b/src/utils/buildCert.js new file mode 100644 index 0000000..680d6d5 --- /dev/null +++ b/src/utils/buildCert.js @@ -0,0 +1,18 @@ +import logger from './logger' + +// Shared envelope for the governance cert panels: flip the waiting flag, hand a +// fresh cert builder to the panel's buildFn (which adds its certificate(s) and +// attaches them to the tx), and route any failure to onError. +export const buildCert = (getCertBuilder, {onWaiting, onError}, buildFn) => { + onWaiting(true) + try { + buildFn(getCertBuilder()) + onWaiting(false) + } catch (error) { + logger.error(error) + onWaiting(false) + onError() + } +} + +export default buildCert diff --git a/src/utils/cslTools.js b/src/utils/cslTools.js index 2e49805..cd3f23b 100644 --- a/src/utils/cslTools.js +++ b/src/utils/cslTools.js @@ -1,3 +1,4 @@ +import logger from './logger' import {protocolParams} from './networkConfig' import {hexToBytes, bytesToHex, wasmMultiassetToJSONs} from './utils' import {Buffer} from 'buffer' @@ -55,31 +56,6 @@ export const getTransactionOutput = (wasmOutputAddress, buildTransactionInput) = // ---- CIP-20 (transaction message metadata, label 674) ---- -// Splits a string into an array of chunks each <= 64 bytes when UTF-8 encoded. -// CSL's metadatum text strings are capped at 64 BYTES (not chars); anything -// larger makes encode_json_str_to_metadatum throw. We accumulate whole code -// points (iterating the string yields code points, not UTF-16 units) so we -// never split a multibyte character mid-sequence. -export const chunkMessageTo64Bytes = (message) => { - const encoder = new TextEncoder() - const chunks = [] - let current = '' - let currentBytes = 0 - for (const ch of message) { - const chBytes = encoder.encode(ch).length - if (currentBytes + chBytes > 64) { - if (current) chunks.push(current) - current = ch - currentBytes = chBytes - } else { - current += ch - currentBytes += chBytes - } - } - if (current) chunks.push(current) - return chunks -} - // Builds an unsigned CIP-20 transaction: // - one explicit 1 ADA output to `receiverBech32` // - a label-674 { "msg": [...] } metadata entry from `messageLines` @@ -215,8 +191,7 @@ export const buildCip20Tx = ({hexUtxos, receiverBech32, messageLines, pickedHexU // Descending comparator for lovelace amount strings (UI sort of decoded UTxOs). // Uses wasm.BigNum (not native BigInt, which trips the react-app ESLint no-undef). -export const compareLovelaceDesc = (aStr, bStr) => - wasm.BigNum.from_str(bStr).compare(wasm.BigNum.from_str(aStr)) +export const compareLovelaceDesc = (aStr, bStr) => wasm.BigNum.from_str(bStr).compare(wasm.BigNum.from_str(aStr)) export const getAddressFromBytes = (changeAddress) => wasm.Address.from_bytes(hexToBytes(changeAddress)) @@ -227,7 +202,6 @@ export const getTransactionWitnessSetNew = () => wasm.TransactionWitnessSet.new( export const getTransactionWitnessSetFromBytes = (witnessHex) => wasm.TransactionWitnessSet.from_bytes(hexToBytes(witnessHex)) - export const getPubKeyHash = (usedAddress) => wasm.BaseAddress.from_address(usedAddress).payment_cred().to_keyhash() export const getNativeScript = (pubKeyHash) => wasm.NativeScript.new_script_pubkey(wasm.ScriptPubkey.new(pubKeyHash)) @@ -239,6 +213,9 @@ export const getAssetName = (assetNameString) => wasm.AssetName.new(Buffer.from( export const getBech32AddressFromHex = (addressHex) => wasm.Address.from_bytes(hexToBytes(addressHex)).to_bech32() +// Decode an array of hex addresses (as returned by the wallet) to bech32. +export const hexArrayToBech32Addresses = (hexAddresses) => hexAddresses.map(getBech32AddressFromHex) + export const getAddressFromBech32 = (bech32Value) => wasm.Address.from_bech32(bech32Value) export const getCslValue = (hexValue) => wasm.Value.from_hex(hexValue) @@ -259,6 +236,9 @@ export const getUtxoFromHex = (hexUtxo) => { return utxo } +// Decode an array of hex UTxOs (as returned by the wallet) to UTxO objects. +export const hexArrayToUtxos = (hexUtxos) => hexUtxos.map(getUtxoFromHex) + export const getTransactionHashFromHex = (txHex) => wasm.TransactionHash.from_hex(txHex) export const getCertificateBuilder = () => wasm.CertificatesBuilder.new() @@ -276,44 +256,60 @@ export const keyHashFromHex = (hexValue) => wasm.Ed25519KeyHash.from_hex(hexValu export const keyHashFromBech32 = (bech32Value) => wasm.Ed25519KeyHash.from_bech32(bech32Value) export const getCslCredentialFromHex = (hexValue) => { - console.debug('[cslTools][getCslCredentialFromHex]::hexValue', hexValue) + logger.debug('[cslTools][getCslCredentialFromHex]::hexValue', hexValue) const keyHash = keyHashFromHex(hexValue) - console.debug('[cslTools][getCslCredentialFromHex]::keyHash', keyHash) + logger.debug('[cslTools][getCslCredentialFromHex]::keyHash', keyHash) const cred = getCredential(keyHash) - console.debug('[cslTools][getCslCredentialFromHex]::cred', cred) + logger.debug('[cslTools][getCslCredentialFromHex]::cred', cred) return cred } export const getCslCredentialFromBech32 = (bech32Value) => { - console.debug('[cslTools][getCslCredentialFromBech32]::bech32Value', bech32Value) + logger.debug('[cslTools][getCslCredentialFromBech32]::bech32Value', bech32Value) const keyHash = keyHashFromBech32(bech32Value) - console.debug('[cslTools][getCslCredentialFromBech32]::keyHash', keyHash) + logger.debug('[cslTools][getCslCredentialFromBech32]::keyHash', keyHash) const cred = getCredential(keyHash) - console.debug('[cslTools][getCslCredentialFromBech32]::cred', cred) + logger.debug('[cslTools][getCslCredentialFromBech32]::cred', cred) return cred } +// Parse a credential input as Hex, falling back to Bech32. Throws (rather than +// returning null) on invalid input so the caller's try/catch handles cleanup +// instead of feeding null into CSL and crashing with the opaque +// "expected instance of Fe". +export const parseCredential = (input) => { + try { + return getCslCredentialFromHex(input) + } catch (err1) { + try { + return getCslCredentialFromBech32(input) + } catch (err2) { + throw new Error(`Invalid credential — not valid Hex or Bech32: ${JSON.stringify(err1)}, ${JSON.stringify(err2)}`) + } + } +} + export const getCslCredentialFromScriptFromBech32 = (bech32Value) => { - console.debug('[cslTools][getCslCredentialFromScriptFromBech32]::bech32Value', bech32Value) + logger.debug('[cslTools][getCslCredentialFromScriptFromBech32]::bech32Value', bech32Value) const scriptHash = wasm.ScriptHash.from_bech32(bech32Value) - console.debug('[cslTools][getCslCredentialFromScriptFromBech32]::scriptHash', scriptHash) + logger.debug('[cslTools][getCslCredentialFromScriptFromBech32]::scriptHash', scriptHash) const cred = getCredentialFromScriptHash(scriptHash) - console.debug('[cslTools][getCslCredentialFromScriptFromBech32]::cred', cred) + logger.debug('[cslTools][getCslCredentialFromScriptFromBech32]::cred', cred) return cred } export const getCslCredentialFromScriptFromHex = (hexValue) => { - console.debug('[cslTools][getCslCredentialFromScriptFromHex]::hexValue', hexValue) + logger.debug('[cslTools][getCslCredentialFromScriptFromHex]::hexValue', hexValue) const scriptHash = wasm.ScriptHash.from_hex(hexValue) - console.debug('[cslTools][getCslCredentialFromScriptFromHex]::scriptHash', scriptHash) + logger.debug('[cslTools][getCslCredentialFromScriptFromHex]::scriptHash', scriptHash) const cred = getCredentialFromScriptHash(scriptHash) - console.debug('[cslTools][getCslCredentialFromScriptFromHex]::cred', cred) + logger.debug('[cslTools][getCslCredentialFromScriptFromHex]::cred', cred) return cred } /** - * - * @param {wasm.Credential} dRepCred + * + * @param {wasm.Credential} dRepCred * @returns {boolean} */ export const dRepIsScript = (dRepCred) => dRepCred.kind() === wasm.CredKind.Script diff --git a/src/utils/ethereumUtils.js b/src/utils/ethereumUtils.js index e9ccab7..5317d6a 100644 --- a/src/utils/ethereumUtils.js +++ b/src/utils/ethereumUtils.js @@ -8,10 +8,14 @@ export const CHAIN_IDS = Object.freeze({ export const chainName = (chainId) => { switch (chainId) { - case CHAIN_IDS.MAINNET: return 'Mainnet' - case CHAIN_IDS.SEPOLIA: return 'Sepolia' - case CHAIN_IDS.HOLESKY: return 'Holesky' - default: return chainId ?? 'Unknown' + case CHAIN_IDS.MAINNET: + return 'Mainnet' + case CHAIN_IDS.SEPOLIA: + return 'Sepolia' + case CHAIN_IDS.HOLESKY: + return 'Holesky' + default: + return chainId ?? 'Unknown' } } @@ -38,21 +42,17 @@ export const ethToHexWei = (ethStr) => { /** * Format an address for display (0x1234...abcd) */ -export const shortAddress = (addr) => - addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : '' +export const shortAddress = (addr) => (addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : '') /** * ERC-20 balanceOf(address) ABI encoding * selector: keccak256('balanceOf(address)')[0..3] = 0x70a08231 */ -export const balanceOfData = (addr) => - '0x70a08231' + addr.slice(2).toLowerCase().padStart(64, '0') +export const balanceOfData = (addr) => '0x70a08231' + addr.slice(2).toLowerCase().padStart(64, '0') /** * ERC-20 transfer(address,uint256) ABI encoding * selector: keccak256('transfer(address,uint256)')[0..3] = 0xa9059cbb */ export const transferData = (to, amountWei) => - '0xa9059cbb' + - to.slice(2).toLowerCase().padStart(64, '0') + - BigInt(amountWei).toString(16).padStart(64, '0') + '0xa9059cbb' + to.slice(2).toLowerCase().padStart(64, '0') + BigInt(amountWei).toString(16).padStart(64, '0') diff --git a/src/utils/ethereumUtils.test.js b/src/utils/ethereumUtils.test.js new file mode 100644 index 0000000..461f441 --- /dev/null +++ b/src/utils/ethereumUtils.test.js @@ -0,0 +1,80 @@ +import { + CHAIN_IDS, + chainName, + weiHexToEth, + ethToHexWei, + shortAddress, + balanceOfData, + transferData, +} from './ethereumUtils' + +describe('chainName', () => { + it('maps known chain ids to readable names', () => { + expect(chainName(CHAIN_IDS.MAINNET)).toBe('Mainnet') + expect(chainName(CHAIN_IDS.SEPOLIA)).toBe('Sepolia') + expect(chainName(CHAIN_IDS.HOLESKY)).toBe('Holesky') + }) + + it('returns the raw id for unknown chains', () => { + expect(chainName('0x99')).toBe('0x99') + }) + + it('returns "Unknown" when no chain id is given', () => { + expect(chainName(undefined)).toBe('Unknown') + }) +}) + +describe('weiHexToEth', () => { + it('converts whole ether', () => { + expect(weiHexToEth('0xde0b6b3a7640000')).toBe('1.0') // 1e18 wei + }) + + it('converts fractional ether and trims trailing zeros', () => { + expect(weiHexToEth('0x6f05b59d3b20000')).toBe('0.5') // 5e17 wei + }) + + it('handles zero', () => { + expect(weiHexToEth('0x0')).toBe('0.0') + }) +}) + +describe('ethToHexWei', () => { + it('converts an ether string to hex wei', () => { + expect(ethToHexWei('1')).toBe('0xde0b6b3a7640000') + }) + + it('round-trips with weiHexToEth', () => { + expect(weiHexToEth(ethToHexWei('2.5'))).toBe('2.5') + }) +}) + +describe('shortAddress', () => { + it('shortens a full address', () => { + expect(shortAddress('0x1234567890abcdef1234567890abcdef12345678')).toBe('0x1234...5678') + }) + + it('returns empty string for falsy input', () => { + expect(shortAddress('')).toBe('') + expect(shortAddress(undefined)).toBe('') + }) +}) + +describe('ERC-20 ABI encoders', () => { + const addr = '0x1234567890abcdef1234567890abcdef12345678' + + it('encodes balanceOf(address)', () => { + const data = balanceOfData(addr) + expect(data.startsWith('0x70a08231')).toBe(true) + // selector (8) + 0x (2) + 64 hex chars of padded address + expect(data.length).toBe(2 + 8 + 64) + expect(data.endsWith(addr.slice(2))).toBe(true) + }) + + it('encodes transfer(address,uint256)', () => { + const data = transferData(addr, '1') + expect(data.startsWith('0xa9059cbb')).toBe(true) + // selector (8) + 0x (2) + 64 (address) + 64 (amount) + expect(data.length).toBe(2 + 8 + 64 + 64) + expect(data.endsWith('1'.padStart(64, '0'))).toBe(true) + }) +}) diff --git a/src/utils/helpFunctions.js b/src/utils/helpFunctions.js index 29b9123..fcce29e 100644 --- a/src/utils/helpFunctions.js +++ b/src/utils/helpFunctions.js @@ -1,4 +1,10 @@ -import {getCslValue, getUtxoFromHex, getBech32AddressFromHex, getPublicKeyFromHex} from './cslTools' +import { + getCslValue, + getBech32AddressFromHex, + getPublicKeyFromHex, + hexArrayToBech32Addresses, + hexArrayToUtxos, +} from './cslTools' // Returns the first element of a wallet-returned array, or throws a clear error // if the wallet returned nothing. Prevents `undefined` from flowing into CSL @@ -19,13 +25,8 @@ export const getBalance = async (api) => { } export const getUTxOs = async (api, amountLovelaces, requestParam = {page: 0, limit: 20}) => { - const utxos = [] const hexUtxos = await api.getUtxos(amountLovelaces, requestParam) - for (const hexUtxo of hexUtxos) { - const utxo = getUtxoFromHex(hexUtxo) - utxos.push(utxo) - } - return utxos + return hexArrayToUtxos(hexUtxos) } export const getChangeAddress = async (api) => { @@ -35,11 +36,7 @@ export const getChangeAddress = async (api) => { export const getRewardAddress = async (api) => { const hexAddresses = await api.getRewardAddresses() - const addresses = [] - for (const hexAddr of hexAddresses) { - addresses.push(getBech32AddressFromHex(hexAddr)) - } - return addresses[0] + return hexArrayToBech32Addresses(hexAddresses)[0] } export const getPubDRepKey = async (api) => { @@ -79,20 +76,12 @@ export const getUnregPubStakeKey = async (api) => { export const getUsedAddress = async (api) => { const requestParam = {page: 0, limit: 1} const hexAddresses = await api.getUsedAddresses(requestParam) - const addresses = [] - for (const hexAddr of hexAddresses) { - addresses.push(getBech32AddressFromHex(hexAddr)) - } - return addresses[0] + return hexArrayToBech32Addresses(hexAddresses)[0] } export const getUnusedAddress = async (api) => { const hexAddresses = await api.getUnusedAddresses() - const addresses = [] - for (const hexAddr of hexAddresses) { - addresses.push(getBech32AddressFromHex(hexAddr)) - } - return addresses[0] + return hexArrayToBech32Addresses(hexAddresses)[0] } const randomBytes = (count) => { diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..c70708c --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,85 @@ +// Debug-gated logger with a runtime on/off switch. +// +// `debug`, `log` and `info` only print when debug output is enabled; +// `warn` and `error` always print so real problems stay visible in production. +// +// Enabled state is resolved in this order: +// 1. A runtime override saved in localStorage (set from the browser console). +// 2. The build-time default: on during local development, or when the app is +// built with REACT_APP_DEBUG=true. +// +// Toggle logs live from the browser console without rebuilding: +// dappLogs.on() // enable and remember across reloads +// dappLogs.off() // disable and remember across reloads +// dappLogs.toggle() // flip current state +// dappLogs.status() // -> true | false +// dappLogs.reset() // forget the override, fall back to the build default +const STORAGE_KEY = 'dapp:debug' + +const buildDefault = process.env.NODE_ENV === 'development' || process.env.REACT_APP_DEBUG === 'true' + +const readOverride = () => { + try { + const value = window.localStorage.getItem(STORAGE_KEY) + if (value === 'true') return true + if (value === 'false') return false + } catch (e) { + // localStorage may be unavailable (SSR, privacy mode) — ignore. + } + return null +} + +const override = readOverride() +let enabled = override === null ? buildDefault : override + +const persist = (value) => { + try { + window.localStorage.setItem(STORAGE_KEY, String(value)) + } catch (e) { + // ignore write failures + } +} + +const setEnabled = (value) => { + enabled = !!value + persist(enabled) + // Always report the switch itself so the user sees the effect immediately. + console.info(`[dApp][logger] logging ${enabled ? 'ENABLED' : 'DISABLED'}`) + return enabled +} + +const logger = { + debug: (...args) => { + if (enabled) console.debug(...args) + }, + log: (...args) => { + if (enabled) console.log(...args) + }, + info: (...args) => { + if (enabled) console.info(...args) + }, + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), +} + +// Expose runtime controls on window so logs can be toggled from the console. +if (typeof window !== 'undefined') { + window.dappLogs = { + on: () => setEnabled(true), + off: () => setEnabled(false), + toggle: () => setEnabled(!enabled), + status: () => enabled, + reset: () => { + try { + window.localStorage.removeItem(STORAGE_KEY) + } catch (e) { + // ignore + } + enabled = buildDefault + console.info(`[dApp][logger] override cleared, logging ${enabled ? 'ENABLED' : 'DISABLED'} (build default)`) + return enabled + }, + } +} + +export default logger diff --git a/src/utils/logger.test.js b/src/utils/logger.test.js new file mode 100644 index 0000000..595294f --- /dev/null +++ b/src/utils/logger.test.js @@ -0,0 +1,80 @@ +// These tests call the app logger's own debug(), not RTL's screen.debug(), so the +// testing-library debugging-utils rule is a false positive here. +/* eslint-disable testing-library/no-debugging-utils */ + +// NODE_ENV is 'test' here, so the build default is "logging off" — which lets us +// assert the gating and the runtime toggle cleanly. +describe('logger', () => { + beforeEach(() => { + jest.resetModules() // re-evaluate logger.js so it re-reads localStorage + window.localStorage.clear() + jest.restoreAllMocks() + }) + + it('does not print debug/log when disabled (build default in test env)', () => { + const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {}) + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) + const logger = require('./logger').default + + logger.debug('nope') + logger.log('nope') + + expect(debugSpy).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() + }) + + it('always prints warn and error regardless of state', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + const logger = require('./logger').default + + logger.warn('w') + logger.error('e') + + expect(warnSpy).toHaveBeenCalledWith('w') + expect(errorSpy).toHaveBeenCalledWith('e') + }) + + it('reads a persisted override on load', () => { + window.localStorage.setItem('dapp:debug', 'true') + const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {}) + const logger = require('./logger').default + + logger.debug('yes') + + expect(debugSpy).toHaveBeenCalledWith('yes') + }) + + it('exposes window.dappLogs controls that toggle output and persist', () => { + jest.spyOn(console, 'info').mockImplementation(() => {}) + const debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {}) + const logger = require('./logger').default + + expect(window.dappLogs.status()).toBe(false) + + window.dappLogs.on() + expect(window.dappLogs.status()).toBe(true) + expect(window.localStorage.getItem('dapp:debug')).toBe('true') + logger.debug('on') + expect(debugSpy).toHaveBeenCalledWith('on') + + debugSpy.mockClear() + window.dappLogs.off() + expect(window.dappLogs.status()).toBe(false) + logger.debug('off') + expect(debugSpy).not.toHaveBeenCalled() + }) + + it('reset() clears the override and falls back to the build default', () => { + window.localStorage.setItem('dapp:debug', 'true') + jest.spyOn(console, 'info').mockImplementation(() => {}) + const logger = require('./logger').default + + expect(window.dappLogs.status()).toBe(true) + window.dappLogs.reset() + + expect(window.localStorage.getItem('dapp:debug')).toBeNull() + expect(window.dappLogs.status()).toBe(false) // build default in test env + void logger + }) +}) diff --git a/src/utils/runApiCall.js b/src/utils/runApiCall.js new file mode 100644 index 0000000..b6c54f8 --- /dev/null +++ b/src/utils/runApiCall.js @@ -0,0 +1,28 @@ +import logger from './logger' + +// Shared lifecycle for the API cards: flip the waiting flag, run the wallet +// call, then publish the raw response and a parsed result — or publish the error. +// Collapses the identical then/catch envelope that every card used to repeat. +// +// call () => Promise the wallet/API call to run +// handlers { onRawResponse, onResponse, onWaiting } (card props) +// options.parse (raw) => result value passed to onResponse (default: identity) +// options.rawText (raw) => shown value passed to onRawResponse (default: identity) +// options.stringify boolean 2nd arg to onResponse (default: true) +export const runApiCall = async (call, {onRawResponse, onResponse, onWaiting}, options = {}) => { + const {parse = (raw) => raw, rawText = (raw) => raw, stringify = true} = options + onWaiting(true) + try { + const raw = await call() + onRawResponse(rawText(raw)) + onResponse(parse(raw), stringify) + } catch (e) { + onRawResponse('') + onResponse(e) + logger.error(e) + } finally { + onWaiting(false) + } +} + +export default runApiCall diff --git a/src/utils/runApiCall.test.js b/src/utils/runApiCall.test.js new file mode 100644 index 0000000..bcf87f8 --- /dev/null +++ b/src/utils/runApiCall.test.js @@ -0,0 +1,47 @@ +import {runApiCall} from './runApiCall' + +const makeHandlers = () => ({ + onRawResponse: jest.fn(), + onResponse: jest.fn(), + onWaiting: jest.fn(), +}) + +describe('runApiCall', () => { + it('toggles waiting on then off around a successful call', async () => { + const handlers = makeHandlers() + await runApiCall(() => Promise.resolve('ok'), handlers) + + expect(handlers.onWaiting).toHaveBeenNthCalledWith(1, true) + expect(handlers.onWaiting).toHaveBeenLastCalledWith(false) + }) + + it('publishes the raw and parsed response (stringify default true)', async () => { + const handlers = makeHandlers() + await runApiCall(() => Promise.resolve('42'), handlers) + + expect(handlers.onRawResponse).toHaveBeenCalledWith('42') + expect(handlers.onResponse).toHaveBeenCalledWith('42', true) + }) + + it('applies parse, rawText and stringify options', async () => { + const handlers = makeHandlers() + await runApiCall(() => Promise.resolve(2), handlers, { + parse: (n) => n * 10, + rawText: (n) => `raw:${n}`, + stringify: false, + }) + + expect(handlers.onRawResponse).toHaveBeenCalledWith('raw:2') + expect(handlers.onResponse).toHaveBeenCalledWith(20, false) + }) + + it('publishes the error and clears raw on failure', async () => { + const handlers = makeHandlers() + const err = new Error('boom') + await runApiCall(() => Promise.reject(err), handlers) + + expect(handlers.onRawResponse).toHaveBeenCalledWith('') + expect(handlers.onResponse).toHaveBeenCalledWith(err) + expect(handlers.onWaiting).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/src/utils/testTxBuilder.js b/src/utils/testTxBuilder.js index 0867df7..739b9ad 100644 --- a/src/utils/testTxBuilder.js +++ b/src/utils/testTxBuilder.js @@ -127,16 +127,12 @@ export const FEATURE_GROUPS = [ /** Canonical test native script for 'script' mode — its hash is the script credential */ function getTestScriptNativeScript() { - return wasm.NativeScript.new_script_pubkey( - wasm.ScriptPubkey.new(wasm.Ed25519KeyHash.from_hex(PAYMENT_KEY_HASH)), - ) + return wasm.NativeScript.new_script_pubkey(wasm.ScriptPubkey.new(wasm.Ed25519KeyHash.from_hex(PAYMENT_KEY_HASH))) } function resolveStakeCred(mode, walletRewardAddrHex) { if (mode === 'wallet' && walletRewardAddrHex) { - const rewardAddr = wasm.RewardAddress.from_address( - wasm.Address.from_bytes(hexToBytes(walletRewardAddrHex)), - ) + const rewardAddr = wasm.RewardAddress.from_address(wasm.Address.from_bytes(hexToBytes(walletRewardAddrHex))) return rewardAddr.payment_cred() } if (mode === 'script') { @@ -148,9 +144,7 @@ function resolveStakeCred(mode, walletRewardAddrHex) { function resolveRewardAddr(mode, walletRewardAddrHex, networkId) { if (mode === 'wallet' && walletRewardAddrHex) { - return wasm.RewardAddress.from_address( - wasm.Address.from_bytes(hexToBytes(walletRewardAddrHex)), - ) + return wasm.RewardAddress.from_address(wasm.Address.from_bytes(hexToBytes(walletRewardAddrHex))) } const cred = resolveStakeCred(mode, walletRewardAddrHex) return wasm.RewardAddress.new(networkId, cred) @@ -190,9 +184,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal // Native script used for mint/burn — defined here so the policy ID is available // when constructing the fake UTXO (burn requires tokens in inputs) const mintPolicyKeyHash = wasm.Ed25519KeyHash.from_hex(PAYMENT_KEY_HASH) - const mintNativeScript = wasm.NativeScript.new_script_pubkey( - wasm.ScriptPubkey.new(mintPolicyKeyHash), - ) + const mintNativeScript = wasm.NativeScript.new_script_pubkey(wasm.ScriptPubkey.new(mintPolicyKeyHash)) // Fake input UTXO: enterprise address with 10,000 ADA // When burn is enabled, also include the tokens to be burned so coin selection can balance @@ -203,10 +195,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal if (enabledFeatures.has('burn')) { const burnMultiAsset = wasm.MultiAsset.new() const burnAssets = wasm.Assets.new() - burnAssets.insert( - wasm.AssetName.new(Buffer.from('4255524e', 'hex')), - strToBigNum('500'), - ) + burnAssets.insert(wasm.AssetName.new(Buffer.from('4255524e', 'hex')), strToBigNum('500')) burnMultiAsset.insert(mintNativeScript.hash(), burnAssets) fakeValue.set_multiasset(burnMultiAsset) } @@ -216,9 +205,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal fakeUtxos.add(fakeUtxo) // Base output: 2 ADA to change address - txBuilder.add_output( - wasm.TransactionOutput.new(changeAddr, wasm.Value.new(strToBigNum('2000000'))), - ) + txBuilder.add_output(wasm.TransactionOutput.new(changeAddr, wasm.Value.new(strToBigNum('2000000')))) // ---- Certificates ---- const certBuilder = wasm.CertificatesBuilder.new() @@ -231,7 +218,11 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal } if (enabledFeatures.has('legacyStakeDeReg')) { const cred = resolveStakeCred(getMode('legacyStakeDeReg'), walletRewardAddrHex) - addCertToBuilder(certBuilder, wasm.Certificate.new_stake_deregistration(wasm.StakeDeregistration.new(cred)), getMode('legacyStakeDeReg')) + addCertToBuilder( + certBuilder, + wasm.Certificate.new_stake_deregistration(wasm.StakeDeregistration.new(cred)), + getMode('legacyStakeDeReg'), + ) hasCerts = true } if (enabledFeatures.has('stakeDelegation')) { @@ -270,9 +261,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal const cred = resolveStakeCred(getMode('voteDelegation'), walletRewardAddrHex) addCertToBuilder( certBuilder, - wasm.Certificate.new_vote_delegation( - wasm.VoteDelegation.new(cred, wasm.DRep.new_always_abstain()), - ), + wasm.Certificate.new_vote_delegation(wasm.VoteDelegation.new(cred, wasm.DRep.new_always_abstain())), getMode('voteDelegation'), ) hasCerts = true @@ -306,11 +295,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal addCertToBuilder( certBuilder, wasm.Certificate.new_vote_registration_and_delegation( - wasm.VoteRegistrationAndDelegation.new( - cred, - wasm.DRep.new_always_abstain(), - strToBigNum('2000000'), - ), + wasm.VoteRegistrationAndDelegation.new(cred, wasm.DRep.new_always_abstain(), strToBigNum('2000000')), ), getMode('voteRegDelegation'), ) @@ -336,18 +321,14 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal if (enabledFeatures.has('drepRegistration')) { const drepCred = wasm.Credential.from_keyhash(wasm.Ed25519KeyHash.from_hex(DREP_KEY_HASH)) certBuilder.add( - wasm.Certificate.new_drep_registration( - wasm.DRepRegistration.new(drepCred, strToBigNum('500000000')), - ), + wasm.Certificate.new_drep_registration(wasm.DRepRegistration.new(drepCred, strToBigNum('500000000'))), ) hasCerts = true } if (enabledFeatures.has('drepRetirement')) { const drepCred = wasm.Credential.from_keyhash(wasm.Ed25519KeyHash.from_hex(DREP_KEY_HASH)) certBuilder.add( - wasm.Certificate.new_drep_deregistration( - wasm.DRepDeregistration.new(drepCred, strToBigNum('500000000')), - ), + wasm.Certificate.new_drep_deregistration(wasm.DRepDeregistration.new(drepCred, strToBigNum('500000000'))), ) hasCerts = true } @@ -359,16 +340,12 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal if (enabledFeatures.has('authCommittee')) { const coldCred = wasm.Credential.from_keyhash(wasm.Ed25519KeyHash.from_hex(COMMITTEE_COLD_HASH)) const hotCred = wasm.Credential.from_keyhash(wasm.Ed25519KeyHash.from_hex(COMMITTEE_HOT_HASH)) - certBuilder.add( - wasm.Certificate.new_committee_hot_auth(wasm.CommitteeHotAuth.new(coldCred, hotCred)), - ) + certBuilder.add(wasm.Certificate.new_committee_hot_auth(wasm.CommitteeHotAuth.new(coldCred, hotCred))) hasCerts = true } if (enabledFeatures.has('resignCommittee')) { const coldCred = wasm.Credential.from_keyhash(wasm.Ed25519KeyHash.from_hex(COMMITTEE_COLD_HASH)) - certBuilder.add( - wasm.Certificate.new_committee_cold_resign(wasm.CommitteeColdResign.new(coldCred)), - ) + certBuilder.add(wasm.Certificate.new_committee_cold_resign(wasm.CommitteeColdResign.new(coldCred))) hasCerts = true } @@ -400,10 +377,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal const votingBuilder = wasm.VotingBuilder.new() const drepCred = wasm.Credential.from_keyhash(wasm.Ed25519KeyHash.from_hex(DREP_KEY_HASH)) const voter = wasm.Voter.new_drep_credential(drepCred) - const govActionId = wasm.GovernanceActionId.new( - wasm.TransactionHash.from_hex(FAKE_TX_HASH), - 0, - ) + const govActionId = wasm.GovernanceActionId.new(wasm.TransactionHash.from_hex(FAKE_TX_HASH), 0) const votingProcedure = wasm.VotingProcedure.new(wasm.VoteKind.Yes) votingBuilder.add(voter, govActionId, votingProcedure) txBuilder.set_voting_builder(votingBuilder) @@ -425,9 +399,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal // ---- Mint / Burn ---- if (enabledFeatures.has('mint') || enabledFeatures.has('burn')) { const mintBuilder = wasm.MintBuilder.new() - const mintWitness = wasm.MintWitness.new_native_script( - wasm.NativeScriptSource.new(mintNativeScript), - ) + const mintWitness = wasm.MintWitness.new_native_script(wasm.NativeScriptSource.new(mintNativeScript)) if (enabledFeatures.has('mint')) { mintBuilder.add_asset( mintWitness, @@ -480,23 +452,14 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal // ---- Collateral ---- if (enabledFeatures.has('collateralInputs')) { - const collateralInput = wasm.TransactionInput.new( - wasm.TransactionHash.from_hex(COLLATERAL_TX_HASH), - 0, - ) + const collateralInput = wasm.TransactionInput.new(wasm.TransactionHash.from_hex(COLLATERAL_TX_HASH), 0) const collateralAmount = wasm.Value.new(strToBigNum('20000000')) const txInputsBuilder = wasm.TxInputsBuilder.new() - txInputsBuilder.add_key_input( - wasm.Ed25519KeyHash.from_hex(PAYMENT_KEY_HASH), - collateralInput, - collateralAmount, - ) + txInputsBuilder.add_key_input(wasm.Ed25519KeyHash.from_hex(PAYMENT_KEY_HASH), collateralInput, collateralAmount) txBuilder.set_collateral(txInputsBuilder) } if (enabledFeatures.has('collateralReturn')) { - txBuilder.set_collateral_return( - wasm.TransactionOutput.new(changeAddr, wasm.Value.new(strToBigNum('10000000'))), - ) + txBuilder.set_collateral_return(wasm.TransactionOutput.new(changeAddr, wasm.Value.new(strToBigNum('10000000')))) } if (enabledFeatures.has('totalCollateral')) { txBuilder.set_total_collateral(strToBigNum('10000000')) @@ -504,9 +467,7 @@ export function buildTestTx(enabledFeatures, credModes, walletRewardAddrHex, wal // ---- Reference Inputs ---- if (enabledFeatures.has('refInputs')) { - txBuilder.add_reference_input( - wasm.TransactionInput.new(wasm.TransactionHash.from_hex(REF_INPUT_TX_HASH), 0), - ) + txBuilder.add_reference_input(wasm.TransactionInput.new(wasm.TransactionHash.from_hex(REF_INPUT_TX_HASH), 0)) } // ---- Script Data Hash ---- diff --git a/src/utils/utils.js b/src/utils/utils.js index 6ff43bf..ee0d536 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -8,6 +8,29 @@ export function hexToBytes(hex) { return Buffer.from(hex, 'hex') } +// Splits a string into an array of chunks each <= 64 bytes when UTF-8 encoded. +// Cardano metadatum text strings (CIP-20 messages, CIP-25 NFT fields) are capped +// at 64 BYTES (not chars). We accumulate whole code points (iterating a string +// yields code points, not UTF-16 units) so we never split a multibyte character. +export function chunkMessageTo64Bytes(message) { + const chunks = [] + let current = '' + let currentBytes = 0 + for (const ch of message) { + const chBytes = Buffer.byteLength(ch, 'utf8') + if (currentBytes + chBytes > 64) { + if (current) chunks.push(current) + current = ch + currentBytes = chBytes + } else { + current += ch + currentBytes += chBytes + } + } + if (current) chunks.push(current) + return chunks +} + export function wasmMultiassetToJSONs(wasmMultiasset) { let assetValue = [] const wasmScriptHashes = wasmMultiasset?.keys() diff --git a/src/utils/utils.test.js b/src/utils/utils.test.js new file mode 100644 index 0000000..a559124 --- /dev/null +++ b/src/utils/utils.test.js @@ -0,0 +1,43 @@ +import {Buffer} from 'buffer' +import {bytesToHex, hexToBytes, chunkMessageTo64Bytes} from './utils' + +describe('bytesToHex / hexToBytes', () => { + it('encodes bytes to a hex string', () => { + expect(bytesToHex([0, 1, 15, 16, 255])).toBe('00010f10ff') + }) + + it('decodes a hex string back to bytes', () => { + expect(Array.from(hexToBytes('00010f10ff'))).toEqual([0, 1, 15, 16, 255]) + }) + + it('round-trips arbitrary data', () => { + const original = [222, 173, 190, 239] + expect(Array.from(hexToBytes(bytesToHex(original)))).toEqual(original) + }) +}) + +describe('chunkMessageTo64Bytes', () => { + it('returns a single chunk for strings <= 64 bytes', () => { + expect(chunkMessageTo64Bytes('hello')).toEqual(['hello']) + }) + + it('returns an empty array for an empty string', () => { + expect(chunkMessageTo64Bytes('')).toEqual([]) + }) + + it('splits on 64-byte boundaries for ASCII', () => { + const input = 'a'.repeat(130) + const chunks = chunkMessageTo64Bytes(input) + expect(chunks.map((c) => c.length)).toEqual([64, 64, 2]) + expect(chunks.join('')).toBe(input) + }) + + it('chunks by bytes, never splitting a multibyte character', () => { + // '€' is 3 UTF-8 bytes; 30 of them = 90 bytes -> must span 2 chunks, + // and each chunk must stay <= 64 bytes without a broken character. + const input = '€'.repeat(30) + const chunks = chunkMessageTo64Bytes(input) + chunks.forEach((c) => expect(Buffer.byteLength(c, 'utf8')).toBeLessThanOrEqual(64)) + expect(chunks.join('')).toBe(input) + }) +})