diff --git a/src/App.tsx b/src/App.tsx index a194827..6fea7b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import { FileManager } from './panels/editor/FileManager' import Tooltip from './components/Tooltip' import { ContextMenu } from './components/ContextMenu' import { ManagementTabs } from './panels/management/ManagementTabs' +import { Settings } from './panels/settings/Settings' function App() { const aiken = useAiken() @@ -28,6 +29,7 @@ function App() { return (() => { return ( +
diff --git a/src/app/store.ts b/src/app/store.ts index 1ebdf72..4a68a26 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -6,7 +6,7 @@ import contextMenuReducer from '../features/contextMenu/contextMenuSlice' import managementReducer from '../features/management/managementSlice' import lucidReducer from '../features/management/lucidSlice' import transactReducer from '../features/management/transactSlice' - +import settingsReducer from '../features/settings/settingsSlice' export const store = configureStore({ reducer: { @@ -16,7 +16,8 @@ export const store = configureStore({ contextMenu: contextMenuReducer, management: managementReducer, lucid: lucidReducer, - transact: transactReducer + transact: transactReducer, + settings: settingsReducer }, }) diff --git a/src/components/LucidProvider.tsx b/src/components/LucidProvider.tsx index e13ef2f..7b3dffb 100644 --- a/src/components/LucidProvider.tsx +++ b/src/components/LucidProvider.tsx @@ -1,87 +1,99 @@ -import { useEffect, useState, createContext, useContext, useRef } from 'react' +import { useEffect, useState, createContext, useContext, useRef, useMemo } from 'react' import { useDispatch, useSelector } from 'react-redux' -import { Emulator, Lucid, generateSeedPhrase } from 'lucid-cardano' +import { Blockfrost, Emulator, Lucid, generateSeedPhrase } from 'lucid-cardano' import { RootState } from '../app/store' -import { Wallet, addWallet, removeWallet } from '../features/management/managementSlice' +import { addWallet } from '../features/management/managementSlice' interface LucidContextState { lucid: Lucid | null isLucidLoading: boolean } - + const LucidContext = createContext({ lucid: null, isLucidLoading: false, }) - + +const genesisSeed = generateSeedPhrase() + export const LucidProvider = ({ children }: { children: React.ReactNode }) => { const [lucid, setLucid] = useState(null) const [isLucidLoading, setIsLucidLoading] = useState(false) - const [genesisWalletOrUndefined, setGenesisWallet] = useState(undefined) - const lucidConfig = useSelector((state: RootState) => state.lucid) - const wallets = useSelector((state: RootState) => state.management.wallets) + const [genesisAddress, setGenesisAddress] = useState('') + const settings = useSelector((state: RootState) => state.settings) const dispatch = useDispatch() - - const areSideEffectsSafe = useRef(true) // is this really the best way to prevent duplicate effect invocations? + + const lucidContext = useMemo(() => { + return { lucid, isLucidLoading } + }, [lucid, isLucidLoading]) useEffect(() => { - let genesisWallet = genesisWalletOrUndefined setIsLucidLoading(true) - const initializeLucid = async () => { - const network = lucidConfig.network === "Emulator" ? "Custom" : lucidConfig.network - if (lucid && lucid.network === network) { - // eventually we need to handle provider changes here - return - } - - let lucidInstance = await Lucid.new(undefined, network) - - let genesisSeed: string | undefined = undefined - let genesisWalletAddress = '' - if (lucidConfig.network === "Emulator" && wallets.length === 0) { - // need to have SOME wallet to provide the emulator provider - genesisSeed = generateSeedPhrase() + Lucid.new(undefined, 'Custom') + .then(lucidInstance => { lucidInstance.selectWalletFromSeed(genesisSeed) - genesisWalletAddress = await lucidInstance.wallet.address() - } else if (wallets.length > 0) { - lucidInstance.selectWalletFromSeed(wallets[0].seed) - } + return lucidInstance.wallet.address() + }) + .then((address) => { + setGenesisAddress(address) + }) + }, []) + + useEffect(() => { + if (!genesisAddress && settings.providerConfig.kind === "emulator") { + return + } + + const initializeLucid = async () => { + setIsLucidLoading(true) - if (lucidConfig.network === "Emulator") { - if (areSideEffectsSafe.current) { - areSideEffectsSafe.current = false - genesisWallet = { - address: genesisWalletAddress, - seed: genesisSeed!! + const network = settings.providerConfig.kind === "emulator" ? "Custom" : settings.network + + let lucidInstance + if (settings.providerConfig.kind === "emulator") { + const emulator = new Emulator([{ + address: genesisAddress, + assets: { + lovelace: 20000000000n } + }]) - dispatch(addWallet(genesisWallet)) - setGenesisWallet(genesisWallet) - - const emulator = new Emulator([{ - address: genesisWallet?.address || '', - assets: { - lovelace: 20000000000n - } - }]) - lucidInstance = await Lucid.new(emulator, network) - - setLucid(lucidInstance) - setIsLucidLoading(false) - } else { - areSideEffectsSafe.current = true - } + lucidInstance = await Lucid.new(emulator, network) + } else if (settings.providerConfig.kind === 'blockfrost') { + lucidInstance = await Lucid.new(new Blockfrost(settings.providerConfig.url, settings.providerConfig.apiKey)) + } else { + throw Error('not implemented') } + + if (!lucidInstance) { + throw Error ('could not create lucid') + } + + return lucidInstance } initializeLucid() - + .then(instance => { + if (settings.providerConfig.kind === 'emulator') { + dispatch(addWallet({ + address: genesisAddress, + seed: genesisSeed + })) + } + setLucid(instance!!) + setIsLucidLoading(false) + }) + .catch(err => { + setIsLucidLoading(false) + console.log('Unexpected problem with lucid...') + console.error(err) + }) return - }, [lucidConfig]) + }, [genesisAddress, settings.network, settings.providerConfig]) return ( - + {children} ) diff --git a/src/features/management/managementSlice.ts b/src/features/management/managementSlice.ts index 4b1788b..bc414ce 100644 --- a/src/features/management/managementSlice.ts +++ b/src/features/management/managementSlice.ts @@ -46,11 +46,16 @@ const managementSlice = createSlice({ state.selectedTabIndex = action.payload }, addWallet(state, action: PayloadAction) { - state.wallets.push(action.payload) + if (!state.wallets.find(wallet => wallet.address === action.payload.address)) { + state.wallets.push(action.payload) + } }, removeWallet(state, action: PayloadAction) { state.wallets.filter(wallet => wallet.address === action.payload) }, + clearWallets(state) { + state.wallets = [] + }, addContract(state, action: PayloadAction) { let version = 0 for (let contract of state.contracts) { @@ -89,6 +94,7 @@ export const { selectTab, addWallet, removeWallet, + clearWallets, addContract, removeContract, clearAddContractError, diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts new file mode 100644 index 0000000..c8a9940 --- /dev/null +++ b/src/features/settings/settingsSlice.ts @@ -0,0 +1,112 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { Network } from 'lucid-cardano' + +export type ProviderKind = 'blockfrost' | 'kupmios' | 'emulator' + +interface BlockfrostProviderConfig { + kind: 'blockfrost' + apiKey: string + url: string +} + +interface KupmiosProviderConfig { + kind: 'kupmios' + kupoUrl: string + ogmiosUrl: string +} + +interface EmulatorConfig { + kind: 'emulator' +} + +type ProviderConfig = BlockfrostProviderConfig | KupmiosProviderConfig | EmulatorConfig + +interface SettingsFormState { + providerKind: ProviderKind + network: Network | 'Emulator' + blockfrost: BlockfrostProviderConfig + kupmios: KupmiosProviderConfig +} + +interface SettingsState { + open: boolean + network: Network + providerConfig: ProviderConfig, + form: SettingsFormState +} + +const initialState: SettingsState = { + open: false, + network: 'Custom', + providerConfig: { + kind: 'emulator' + }, + form: { + providerKind: 'emulator', + network: 'Emulator', + blockfrost: { + kind: 'blockfrost', + apiKey: '', // dont check this in please + url: 'https://cardano-mainnet.blockfrost.io/api/v0' + }, + kupmios: { + kind: 'kupmios', + kupoUrl: '', + ogmiosUrl: '' + }, + } +} + +const settingsSlice = createSlice({ + name: 'tooltip', + initialState, + reducers: { + toggleSettings: (state) => { + state.open = !state.open + }, + saveUpdatedSettings: (state) => { + state.open = false + state.network = state.form.network === 'Emulator' ? 'Custom' : state.form.network + + if (state.form.providerKind === 'blockfrost') { + state.providerConfig = state.form.blockfrost + } else if (state.form.providerKind === 'kupmios') { + state.providerConfig = state.form.kupmios + } else if (state.form.providerKind === 'emulator') { + state.providerConfig = { kind: 'emulator' } + } else { + throw Error('not implemented') + } + }, + setFormProviderKind: (state, action: PayloadAction) => { + state.form.providerKind = action.payload + }, + setFormNetwork: (state, action: PayloadAction) => { + state.form.network = action.payload + + if (state.form.network === 'Emulator' && state.form.providerKind !== 'emulator') { + state.form.providerKind = 'emulator' + } + + if (state.form.network !== 'Emulator' && state.form.providerKind === 'emulator') { + state.form.providerKind = 'blockfrost' + } + }, + setBlockfrostConfig: (state, action: PayloadAction) => { + state.form.blockfrost = action.payload + }, + setKupmiosConfig: (state, action: PayloadAction) => { + state.form.blockfrost = action.payload + } + } +}) + +export const { + toggleSettings, + saveUpdatedSettings, + setFormProviderKind, + setBlockfrostConfig, + setKupmiosConfig, + setFormNetwork +} = settingsSlice.actions +export default settingsSlice.reducer \ No newline at end of file diff --git a/src/panels/management/ManagementTopBar.tsx b/src/panels/management/ManagementTopBar.tsx index ca4d884..043c40d 100644 --- a/src/panels/management/ManagementTopBar.tsx +++ b/src/panels/management/ManagementTopBar.tsx @@ -1,12 +1,22 @@ -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import '../TopBar.css'; import { RootState } from '../../app/store'; +import { toggleSettings } from '../../features/settings/settingsSlice'; function ManagementTopBar() { - const network = useSelector((state: RootState) => state.lucid.network) + const network = useSelector((state: RootState) => state.settings.network) + const dispatch = useDispatch() + + const displayNetwork = network === 'Custom' ? 'Emulator' : network return (
-
Network: {network}
+
Network: {displayNetwork}
+
dispatch(toggleSettings())} + >Settings +
+ {/*
Status: Not Connected
*/}
) diff --git a/src/panels/management/transact/Submit.tsx b/src/panels/management/transact/Submit.tsx index 13dce35..5e602ac 100644 --- a/src/panels/management/transact/Submit.tsx +++ b/src/panels/management/transact/Submit.tsx @@ -191,8 +191,6 @@ async function buildTransaction(lucid: Lucid, transactState: TransactState, file try { const redeemerJson = JSON.parse(redeemerFile.content) redeemer = constructObject(redeemerJson) - - console.log(redeemer) } catch (e: any) { if (e.message && e.message.includes('JSON.parse')) { throw Error(`Invalid JSON in ${redeemerFile.name}`) diff --git a/src/panels/management/transact/UtxoSelector.tsx b/src/panels/management/transact/UtxoSelector.tsx index 96b063c..cad54f5 100644 --- a/src/panels/management/transact/UtxoSelector.tsx +++ b/src/panels/management/transact/UtxoSelector.tsx @@ -3,12 +3,12 @@ import { RootState } from "../../../app/store" import { useLucid } from "../../../components/LucidProvider" import React, { useEffect, useRef, useState } from "react" import { shortenAddress } from "../../../util/strings" -import { Emulator, UTxO } from "lucid-cardano" +import { UTxO } from "lucid-cardano" import { Utxo } from "../wallet/Wallet" import { constructObject } from "../../../util/data" import { Spend, addSpend, clearAddSpendError, setAddSpendError } from "../../../features/management/transactSlice" import { useTooltip } from "../../../hooks/useTooltip" -import { SerializableUTxO, serializeUtxos } from "../../../util/utxo" +import { serializeUtxos } from "../../../util/utxo" export type UtxoSource = 'wallet' | 'contract' | 'custom' @@ -34,7 +34,7 @@ function UtxoSelector() { } else if (contracts.length > 0) { return 'contract' as UtxoSource } else { - return 'custom' as UtxoSource + return 'wallet' as UtxoSource } })()) @@ -55,18 +55,26 @@ function UtxoSelector() { const usableUtxos = sourceUtxos.filter(sourceUtxo => { return !usedUtxos.includes(sourceUtxo.txHash + sourceUtxo.outputIndex) }) - - useEffect(() => { // utxo fetching for selected address + + if (utxoSource === 'wallet' && wallets.length === 0 && contracts.length > 0) { + setUtxoSource('contract') + } + + useEffect(() => { if (isLucidLoading) { return } + if (sourceAddress === '') { + return + } + const lucid = lucidOrNull!! if (utxoSource === 'wallet') { const selectedWallet = wallets.find(wallet => wallet.address === sourceAddress) if (!selectedWallet) { - return // error? + throw Error(`Expected to be able to find wallet with address ${sourceAddress}`) } lucid.selectWalletFromSeed(selectedWallet.seed) @@ -75,18 +83,19 @@ function UtxoSelector() { setSourceUtxos(utxos) }) .catch(console.error) - } else { - if (utxoSource === 'custom') { - try { - const _addressValidityCheck = lucid.utils.getAddressDetails(sourceAddress) - } catch (_) { - return // don't search when the address isn't valid - } + } else if (utxoSource === 'contract') { + const selectedContract = contracts.find(contract => contract.address === sourceAddress) + + if (!selectedContract) { + throw Error(`Expected to be able to find contract with address ${sourceAddress}`) } - lucid.provider.getUtxos(sourceAddress) - .then(setSourceUtxos) - .catch(console.error) + if (selectedContract?.address) { + lucid.provider.getUtxos(selectedContract?.address) + .then((utxos) => { + setSourceUtxos(utxos) + }) + } } }, [isLucidLoading, utxoSource, sourceAddress, numTransactions]) @@ -132,15 +141,6 @@ function UtxoSelector() { } ) - const addressTextInput = ( - ) => { - setSourceAddress(e.target.value) - }} - /> - ) return isLucidLoading ?
Loading lol
: (
@@ -149,6 +149,7 @@ function UtxoSelector() {
Source
Address
- { - utxoSource === 'custom' ? - addressTextInput : - addressDropdownInput - } + { addressDropdownInput }
{ diff --git a/src/panels/management/wallet/Wallet.tsx b/src/panels/management/wallet/Wallet.tsx index 0f3e849..6e75bec 100644 --- a/src/panels/management/wallet/Wallet.tsx +++ b/src/panels/management/wallet/Wallet.tsx @@ -3,10 +3,10 @@ import { Wallet } from "../../../features/management/managementSlice" import { shortenAddress } from "../../../util/strings" import { Lucid, UTxO, toText } from "lucid-cardano" import Copy from "../../../components/Copy" +import { useLucid } from "../../../components/LucidProvider" type WalletUtxosProps = { wallet: Wallet - lucid: Lucid } type UtxoProps = { @@ -37,21 +37,26 @@ function Utxo({ utxo, className, withCopy = true }: UtxoProps) { ) } -function WalletComponent({ wallet, lucid }: WalletUtxosProps) { +function WalletComponent({ wallet }: WalletUtxosProps) { const [utxos, setUtxos] = useState(undefined) const [utxoError, setUtxoError] = useState(undefined) + const { lucid, isLucidLoading } = useLucid() useEffect(() => { + if (!lucid || isLucidLoading) { + return + } + lucid.provider.getUtxos(wallet.address) .then(utxos => { setUtxos(utxos) }) .catch((e: any) => { - setUtxoError(e.message) // what type is e actually tho + setUtxoError(e.message) }) - }, [wallet, lucid]) + }, [wallet, lucid, isLucidLoading]) - if (!lucid) { + if (!lucid || isLucidLoading) { return } diff --git a/src/panels/management/wallet/Wallets.tsx b/src/panels/management/wallet/Wallets.tsx index 1a0a44f..7940192 100644 --- a/src/panels/management/wallet/Wallets.tsx +++ b/src/panels/management/wallet/Wallets.tsx @@ -7,19 +7,17 @@ import { addWallet } from "../../../features/management/managementSlice" import { generateSeedPhrase } from "lucid-cardano" function Wallets() { - const { lucid: lucidOrUndefined} = useLucid() + const { lucid: lucidOrUndefined } = useLucid() const wallets = useSelector((state: RootState) => state.management.wallets) const dispatch = useDispatch() - - - const lucid = lucidOrUndefined!! - + return (
Wallets
{ wallets.map(wallet => { - return + return }) }
diff --git a/src/panels/settings/Settings.css b/src/panels/settings/Settings.css new file mode 100644 index 0000000..5a7b486 --- /dev/null +++ b/src/panels/settings/Settings.css @@ -0,0 +1,107 @@ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; + } + + .modal-content { + background-color: rgb(27, 27, 27); + padding: 20px; + border-radius: 5px; + display: flex; + flex-direction: column; + align-items: start; + gap: 15px; + min-width: 30vw; + min-height: 60vh; + font-size:13px; + } + + .setting { + display: flex; + flex-direction: column; + align-items: start; + } + + .settings-header { + font-size: 28px; + } + + .settings-section-header { + font-size: 24px; + font-weight: 300; + border-bottom: 1px solid white; + } + + .settings-subsection { + display: flex; + flex-direction: column; + gap: 10px; + } + + .settings-form-buttons { + display: flex; + gap: 10px; + } + + .settings-warning { + color: orange; + } + + .settings-error { + color: rgb(255, 113, 113); + min-height: 20px; + } + + + .lds-ring { /* https://loading.io/css/ */ + /* change color here */ + color: #eaeaea + } + .lds-ring, + .lds-ring div { + box-sizing: border-box; + margin-left: 10px; + } + .lds-ring { + display: inline-block; + position: relative; + width: 20px; + height: 20px; + } + .lds-ring div { + box-sizing: border-box; + display: block; + position: absolute; + width: 20px; + height: 20px; + margin: 2%; + border: 2px solid currentColor; + border-radius: 50%; + animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + border-color: currentColor transparent transparent transparent; + } + .lds-ring div:nth-child(1) { + animation-delay: -0.45s; + } + .lds-ring div:nth-child(2) { + animation-delay: -0.3s; + } + .lds-ring div:nth-child(3) { + animation-delay: -0.15s; + } + @keyframes lds-ring { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } \ No newline at end of file diff --git a/src/panels/settings/Settings.tsx b/src/panels/settings/Settings.tsx new file mode 100644 index 0000000..b24d052 --- /dev/null +++ b/src/panels/settings/Settings.tsx @@ -0,0 +1,190 @@ +import { useDispatch, useSelector } from "react-redux" +import { RootState } from "../../app/store" +import { ProviderKind, setBlockfrostConfig, setFormNetwork, setFormProviderKind, saveUpdatedSettings, toggleSettings } from "../../features/settings/settingsSlice" +import './Settings.css' +import { capitalize } from "../../util/strings" +import { Blockfrost, Lucid, Network } from "lucid-cardano" +import { clearWallets } from "../../features/management/managementSlice" +import { useState } from "react" + + + +function Settings() { + const [isSaving, setIsSaving] = useState(false) + const [savingError, setSavingError] = useState('') + const settings = useSelector((state: RootState) => state.settings) + const dispatch = useDispatch() + + const networks: (Network | 'Emulator')[] = ['Emulator', 'Preview', 'Preprod', 'Mainnet'] + const providerKinds: ProviderKind[] = (() => { + if (settings.form.network === 'Emulator') { + return ['emulator'] + } else { + return ['blockfrost']//, 'kupmios'] + } + })() + + const providerSpecificForm = (() => { + if (settings.form.providerKind === 'emulator') { + return (
) + } else if (settings.form.providerKind === 'blockfrost') { + return ( +
+
+
API Key
+ { + dispatch(setBlockfrostConfig({ + ...settings.form.blockfrost, + apiKey: e.target.value + })) + }} + /> +
+ +
+
API URL
+ { + dispatch(setBlockfrostConfig({ + ...settings.form.blockfrost, + url: e.target.value + })) + }} + /> +
+ +
+ ) + } else { + return (
Kupmios Settings
) + } + })() + + const warning = (() => { + if ( + (settings.providerConfig.kind === 'emulator' && settings.form.providerKind !== 'emulator') || + (settings.providerConfig.kind !== 'emulator' && settings.form.providerKind === 'emulator') + ) { + return 'Changing between emulated and live networks will clear the list of registered wallets and any emulator state.' + } + + return '' + })() + + return ( +
+ {settings.open && ( +
dispatch(toggleSettings())}> +
e.stopPropagation()}> + + Settings + { isSaving ?
: null } +
+ { warning ? { warning } : null } + { savingError } +
+ Provider +
+ +
+
Network
+ +
+
+
Provider Type
+ +
+ + {providerSpecificForm} + +
+ + + + + +
+
+
+ )} +
+ ) +} + +export { Settings } \ No newline at end of file diff --git a/src/util/strings.ts b/src/util/strings.ts index b44134b..a654246 100644 --- a/src/util/strings.ts +++ b/src/util/strings.ts @@ -38,4 +38,8 @@ function shortenAddress(address: string, startLength: number = 12, endLength: nu return address } -export { findLineNumberByCharIndex, splitFilename, shortenAddress} \ No newline at end of file +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +export { findLineNumberByCharIndex, splitFilename, shortenAddress, capitalize} \ No newline at end of file