Repository: joinmarket-webui/jam
Pull request #687 (2023-10-26 21:52:03)

fix: use orderbook.json instead of parsing html table
Closes https://github.com/joinmarket-webui/jam/issues/454.
Closes https://github.com/joinmarket-webui/jam/issues/561.


- Fetches `orderbook.json` instead of parsing the html table to get data.
- Uses normalized fidelity bond value
- Removes column "Miner Fee Contribution"

### :camera_flash: Before/After
<img src="https://github.com/joinmarket-webui/jam/assets/3358649/da046b8a-e374-4e0b-b5b6-4e629f130154" width=550 />
<img src="https://github.com/joinmarket-webui/jam/assets/3358649/bd4a54c8-dc15-4f7f-be58-a5c37e8eb16a" width=550 />




Patch:
diff --git a/src/components/App.tsx b/src/components/App.tsx
index f3d3993..a71aba1 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import {
@@ -27,7 +27,7 @@ import { isDebugFeatureEnabled } from '../constants/debugFeatures'
 import CreateWallet from './CreateWallet'
 import ImportWallet from './ImportWallet'
 import Earn from './Earn'
-import ErrorPage, { ErrorThrowingComponent } from './ErrorPage'
+import ErrorPage from './ErrorPage'
 import Footer from './Footer'
 import Jam from './Jam'
 import Layout from './Layout'
@@ -39,6 +39,7 @@ import Send from './Send'
 import RescanChain from './RescanChain'
 import Settings from './Settings'
 import Wallets from './Wallets'
+const DevSetupPage = lazy(() => import('./DevSetupPage'))
 
 export default function App() {
   const { t } = useTranslation()
@@ -168,6 +169,17 @@ export default function App() {
               {isDebugFeatureEnabled('errorExamplePage') && (
                 <Route id="error-example" path={routes.__errorExample} element={<ErrorThrowingComponent />} />
               )}
+              {isDebugFeatureEnabled('devSetupPage') && (
+                <Route
+                  id="dev-env"
+                  path={routes.__devSetup}
+                  element={
+                    <Suspense fallback={<Loading />}>
+                      <DevSetupPage />
+                    </Suspense>
+                  }
+                />
+              )}
               <Route id="404" path="*" element={<Navigate to={routes.home} replace={true} />} />
             </>
           )}
@@ -208,6 +220,23 @@ export default function App() {
   )
 }
 
+const Loading = () => {
+  const { t } = useTranslation()
+  return (
+    <div className="text-center">
+      <rb.Spinner as="span" animation="border" size="sm" role="status" aria-hidden="true" className="me-2" />
+      {t('global.loading')}
+    </div>
+  )
+}
+
+const ErrorThrowingComponent = () => {
+  useEffect(() => {
+    throw new Error('This error is thrown on purpose. Only to be used for testing.')
+  }, [])
+  return <></>
+}
+
 const RELOAD_WALLET_INFO_DELAY: {
   AFTER_RESCAN: Milliseconds
   AFTER_UNLOCK: Milliseconds
diff --git a/src/components/DevSetupPage.tsx b/src/components/DevSetupPage.tsx
new file mode 100644
index 0000000..2894de4
--- /dev/null
+++ b/src/components/DevSetupPage.tsx
@@ -0,0 +1,111 @@
+import { Link } from 'react-router-dom'
+import Sprite from './Sprite'
+import PageTitle from './PageTitle'
+import { routes } from '../constants/routes'
+
+const DEFAULT_BASIC_AUTH = {
+  user: 'joinmarket',
+  password: 'joinmarket',
+}
+
+const LINK_JM_REGTEST_JOINMARKET2 = 'http://localhost:29080'
+const LINK_JM_REGTEST_JOINMARKET2_AUTH = DEFAULT_BASIC_AUTH
+const LINK_JM_REGTEST_JOINMARKET3 = 'http://localhost:30080'
+const LINK_JM_REGTEST_EXPLORER = 'http://localhost:3002'
+const LINK_JM_REGTEST_EXPLORER_AUTH = DEFAULT_BASIC_AUTH
+const LINK_JM_REGTEST_RPC_TERMINAL = `${LINK_JM_REGTEST_EXPLORER}/rpc-terminal`
+
+export default function DevSetupPage() {
+  return (
+    <div>
+      <PageTitle title="Development setup" subtitle="" />
+      <div className="d-flex flex-column gap-3">
+        <div className="mb-4">
+          <h5>Test Wallet</h5>
+          <div className="ms-3 my-2">
+            Name: <span className="font-monospace">Satoshi</span>
+            <br />
+            Password: <span className="font-monospace">test</span>
+          </div>
+        </div>
+      </div>
+
+      <div className="d-flex flex-column gap-3">
+        <div className="mb-4">
+          <h5>Links</h5>
+          <div className="my-2">
+            <Link className="link-dark" to={routes.__errorExample}>
+              Error Example Page
+            </Link>
+          </div>
+        </div>
+      </div>
+
+      <div className="mb-4">
+        <h5>Jam Instances</h5>
+        <div>
+          <div className="d-flex align-items-center">
+            <Sprite symbol="logo" width="24" height="24" className="me-2" />
+            <a href={LINK_JM_REGTEST_JOINMARKET2} target="_blank" rel="noopener noreferrer" className="link-dark">
+              jm_regtest_joinmarket2 ({LINK_JM_REGTEST_JOINMARKET2})
+            </a>
+            <span className="badge rounded-pill bg-primary ms-2">secondary</span>
+          </div>
+
+          <div className="ms-5 my-2">
+            Basic Authentication
+            <br />
+            <small>
+              User: <span className="font-monospace">{LINK_JM_REGTEST_JOINMARKET2_AUTH.user}</span>
+              <br />
+              Password: <span className="font-monospace">{LINK_JM_REGTEST_JOINMARKET2_AUTH.password}</span>
+            </small>
+          </div>
+          <div className="d-flex align-items-center">
+            <Sprite symbol="logo" width="24" height="24" className="me-2" />
+            <a href={LINK_JM_REGTEST_JOINMARKET3} target="_blank" rel="noopener noreferrer" className="link-dark">
+              jm_regtest_joinmarket3 ({LINK_JM_REGTEST_JOINMARKET3})
+            </a>
+            <span className="badge rounded-pill bg-success ms-2">tertiary</span>
+          </div>
+        </div>
+      </div>
+
+      <div className="mb-4">
+        <h5>Block Explorer</h5>
+        <div>
+          {' '}
+          <div className="d-flex align-items-center">
+            <Sprite symbol="block" width="24" height="24" className="me-2" />
+
+            <a href={LINK_JM_REGTEST_EXPLORER} target="_blank" rel="noopener noreferrer" className="link-dark">
+              jm_regtest_explorer ({LINK_JM_REGTEST_EXPLORER})
+            </a>
+          </div>
+          <div className="ms-5 my-2">
+            Basic Authentication
+            <br />
+            <small>
+              User: <span className="font-monospace">{LINK_JM_REGTEST_EXPLORER_AUTH.user}</span>
+              <br />
+              Password: <span className="font-monospace">{LINK_JM_REGTEST_EXPLORER_AUTH.password}</span>
+            </small>
+          </div>
+        </div>
+        <div>
+          <div className="d-flex align-items-center">
+            <Sprite symbol="console" width="24" height="24" className="me-2" />
+
+            <a href={LINK_JM_REGTEST_RPC_TERMINAL} target="_blank" rel="noopener noreferrer" className="link-dark">
+              Regtest RPC Terminal ({LINK_JM_REGTEST_RPC_TERMINAL})
+            </a>
+          </div>
+          <div className="ms-5 my-2">
+            Mine a block, e.g.:
+            <pre>generatetoaddress 1 bcrt1qrnz0thqslhxu86th069r9j6y7ldkgs2tzgf5wx</pre>
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
diff --git a/src/components/Earn.tsx b/src/components/Earn.tsx
index 857980a..3acb9da 100644
--- a/src/components/Earn.tsx
+++ b/src/components/Earn.tsx
@@ -2,10 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
 import { Formik, FormikErrors } from 'formik'
 import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
+import { TFunction } from 'i18next'
 import { useSettings } from '../context/SettingsContext'
 import { CurrentWallet, useCurrentWalletInfo, useReloadCurrentWalletInfo } from '../context/WalletContext'
 import { useServiceInfo, useReloadServiceInfo, Offer } from '../context/ServiceInfoContext'
-import { factorToPercentage, isValidNumber, percentageToFactor } from '../utils'
+import { factorToPercentage, isAbsoluteOffer, isRelativeOffer, isValidNumber, percentageToFactor } from '../utils'
 import * as Api from '../libs/JmWalletApi'
 import * as fb from './fb/utils'
 import Sprite from './Sprite'
@@ -19,7 +20,6 @@ import { OrderbookOverlay } from './Orderbook'
 import Balance from './Balance'
 import styles from './Earn.module.css'
 import Accordion from './Accordion'
-import { TFunction } from 'i18next'
 
 // In order to prevent state mismatch, the 'maker stop' response is delayed shortly.
 // Even though the API response suggests that the maker has started or stopped immediately, it seems that this is not always the case.
@@ -31,14 +31,8 @@ const MAKER_STOP_RESPONSE_DELAY_MS = 2_000
 // that the UTXO corresponding to the fidelity bond is correctly marked as such.
 const RELOAD_FIDELITY_BONDS_DELAY_MS = 2_000
 
-const OFFERTYPE_REL = 'sw0reloffer'
-const OFFERTYPE_ABS = 'sw0absoffer'
-
-// can be any of ['sw0reloffer', 'swreloffer', 'reloffer']
-const isRelativeOffer = (offertype: string) => offertype.includes('reloffer')
-
-// can be any of ['sw0absoffer', 'swabsoffer', 'absoffer']
-const isAbsoluteOffer = (offertype: string) => offertype.includes('absoffer')
+const OFFERTYPE_REL: Api.OfferType = 'sw0reloffer'
+const OFFERTYPE_ABS: Api.OfferType = 'sw0absoffer'
 
 const FORM_INPUT_LOCAL_STORAGE_KEYS = {
   offertype: 'jm-offertype',
diff --git a/src/components/ErrorPage.tsx b/src/components/ErrorPage.tsx
index 4e09c1d..5ad9159 100644
--- a/src/components/ErrorPage.tsx
+++ b/src/components/ErrorPage.tsx
@@ -3,14 +3,6 @@ import * as rb from 'react-bootstrap'
 import { useRouteError } from 'react-router-dom'
 import PageTitle from './PageTitle'
 import { t } from 'i18next'
-import { useEffect } from 'react'
-
-export function ErrorThrowingComponent() {
-  useEffect(() => {
-    throw new Error('This error is thrown on purpose. Only to be used for testing.')
-  }, [])
-  return <></>
-}
 
 interface ErrorViewProps {
   title: string
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index db77fcb..f54261c 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -8,8 +8,10 @@ import { useCurrentWallet } from '../context/WalletContext'
 import Sprite from './Sprite'
 import Cheatsheet from './Cheatsheet'
 import packageInfo from '../../package.json'
-import { isDevMode } from '../constants/debugFeatures'
+import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
 import { toSemVer } from '../utils'
+import { Link } from 'react-router-dom'
+import { routes } from '../constants/routes'
 
 const APP_DISPLAY_VERSION = (() => {
   const version = toSemVer(packageInfo.version)
@@ -52,7 +54,7 @@ export default function Footer() {
               <rb.Card.Title className="text-center mb-3">{t('footer.warning_alert_title')}</rb.Card.Title>
               <p>{t('footer.warning_alert_text')}</p>
               <p className="text-secondary">
-                JoinMarket: v{serviceInfo?.server?.version?.raw || 'unknown'}
+                JoinMarket: v{serviceInfo?.server?.version?.raw || '_unknown'}
                 <br />
                 Jam: v{APP_DISPLAY_VERSION}
               </p>
@@ -68,13 +70,13 @@ export default function Footer() {
 
       <rb.Nav as="footer" className="border-top py-2">
         <rb.Container fluid="xl" className="d-flex justify-content-center py-2 px-4">
-          <div className="d-none d-md-flex flex-1 order-0 justify-content-start align-items-center">
-            <div className="warning-hint text-start text-secondary">
+          <div className="flex-1 order-0 justify-content-start align-items-center">
+            <div className="text-small text-start text-secondary">
               <Trans i18nKey="footer.warning">
                 This is pre-alpha software.
                 <rb.Button
                   variant="link"
-                  className="warning-hint text-start border-0 p-0 text-secondary"
+                  className="text-small text-start border-0 p-0 text-secondary"
                   onClick={() => setShowBetaWarning(true)}
                 >
                   Read this before using.
@@ -102,7 +104,14 @@ export default function Footer() {
             )}
           </div>
           <div className="d-flex flex-1 order-2 justify-content-end align-items-center gap-1">
-            <div className="warning-hint text-start text-secondary d-none d-md-block pe-1">
+            {isDebugFeatureEnabled('devSetupPage') && (
+              <div className="d-none d-md-block text-small text-start mx-1">
+                <Link className="text-warning" to={routes.__devSetup}>
+                  Dev Setup
+                </Link>
+              </div>
+            )}
+            <div className="text-small text-start text-secondary mx-1">
               <a
                 href="https://github.com/joinmarket-webui/jam/tags"
                 target="_blank"
@@ -112,7 +121,7 @@ export default function Footer() {
                 v{APP_DISPLAY_VERSION}
               </a>
             </div>
-            <div className="d-flex gap-2 pe-2">
+            <div className="d-flex gap-2 me-2">
               <a
                 href="https://github.com/joinmarket-webui/jam"
                 target="_blank"
@@ -130,7 +139,7 @@ export default function Footer() {
                 <Sprite symbol="telegram" width="18px" height="18px" />
               </a>
             </div>
-            <div className="d-flex text-secondary">|</div>
+            <div className="text-secondary">|</div>
             <div className="d-flex">
               <rb.OverlayTrigger
                 delay={{ hide: 300, show: 200 }}
diff --git a/src/components/LogOverlay.module.css b/src/components/LogOverlay.module.css
index c3b7843..99e66bd 100644
--- a/src/components/LogOverlay.module.css
+++ b/src/components/LogOverlay.module.css
@@ -1,5 +1,5 @@
 .logContentPlaceholder {
-  height: 1rem;
+  height: 2.625rem;
   margin: 1px 0;
 }
 
diff --git a/src/components/LogOverlay.tsx b/src/components/LogOverlay.tsx
index 29e30ef..7921d0b 100644
--- a/src/components/LogOverlay.tsx
+++ b/src/components/LogOverlay.tsx
@@ -7,6 +7,7 @@ import { useSettings } from '../context/SettingsContext'
 import { CurrentWallet } from '../context/WalletContext'
 import Sprite from './Sprite'
 import styles from './LogOverlay.module.css'
+import { isDevMode } from '../constants/debugFeatures'
 
 const JMWALLETD_LOG_FILE_NAME = 'jmwalletd_stdout.log'
 
@@ -74,7 +75,7 @@ export function LogOverlay({ currentWallet, show, onHide }: LogOverlayProps) {
   const [alert, setAlert] = useState<SimpleAlert>()
   const [isInitialized, setIsInitialized] = useState(false)
   const [isLoading, setIsLoading] = useState(true)
-  const [content, setContent] = useState<string | null>(null)
+  const [content, setContent] = useState<string>()
 
   const refresh = useCallback(
     (signal: AbortSignal) => {
@@ -85,9 +86,14 @@ export function LogOverlay({ currentWallet, show, onHide }: LogOverlayProps) {
           setAlert(undefined)
           setContent(data)
         })
-        .catch((err) => {
+        .catch((e) => {
           if (signal.aborted) return
-          setAlert({ variant: 'danger', message: t('logs.error_loading_logs_failed') })
+          setAlert({
+            variant: 'danger',
+            message: t('logs.error_loading_logs_failed', {
+              reason: e.message || t('global.errors.reason_unknown'),
+            }),
+          })
         })
         .finally(() => {
           if (signal.aborted) return
@@ -138,7 +144,7 @@ export function LogOverlay({ currentWallet, show, onHide }: LogOverlayProps) {
       <rb.Offcanvas.Body>
         <rb.Container fluid="lg" className="py-3">
           {!isInitialized && isLoading ? (
-            Array(12)
+            Array(5)
               .fill('')
               .map((_, index) => {
                 return (
@@ -149,6 +155,13 @@ export function LogOverlay({ currentWallet, show, onHide }: LogOverlayProps) {
               })
           ) : (
             <>
+              {alert && !content && isDevMode() && (
+                <div className="my-4">
+                  <span className="badge rounded-pill bg-warning me-2">dev</span>
+                  In order to test the log file feature, start the application with
+                  <code className="mx-2">npm run dev:start:secondary</code>.
+                </div>
+              )}
               {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
               {content && (
                 <rb.Row>
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index 5df9312..2fb27e4 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -299,6 +299,11 @@ export default function Navbar() {
                     <span>{t('navbar.menu')}</span>
                   </rb.Navbar.Toggle>
                 </div>
+                {isDebugFeatureEnabled('fastThemeToggle') && (
+                  <rb.Nav.Item className="d-none d-md-flex align-items-center pe-2">
+                    <FastThemeToggle />
+                  </rb.Nav.Item>
+                )}
                 <rb.Navbar.Offcanvas className={`navbar-offcanvas navbar-${settings.theme}`} placement="end">
                   <rb.Offcanvas.Header>
                     <rb.Offcanvas.Title>{t('navbar.title')}</rb.Offcanvas.Title>
@@ -314,6 +319,15 @@ export default function Navbar() {
                           {t('navbar.button_create_wallet')}
                         </Link>
                       </rb.Nav.Item>
+                      <rb.Nav.Item>
+                        <Link
+                          to={routes.importWallet}
+                          onClick={() => isExpanded && setIsExpanded(false)}
+                          className="nav-link"
+                        >
+                          {t('navbar.button_import_wallet')}
+                        </Link>
+                      </rb.Nav.Item>
                     </rb.Nav>
                   </rb.Offcanvas.Body>
                 </rb.Navbar.Offcanvas>
diff --git a/src/components/Orderbook.module.css b/src/components/Orderbook.module.css
index 0e98de8..eecfd95 100644
--- a/src/components/Orderbook.module.css
+++ b/src/components/Orderbook.module.css
@@ -1,4 +1,4 @@
-.orderbook-line-placeholder {
+.orderbookContentPlaceholder {
   height: 2.625rem;
   margin: 1px 0;
 }
diff --git a/src/components/Orderbook.tsx b/src/components/Orderbook.tsx
index 5e192d1..a9e5b94 100644
--- a/src/components/Orderbook.tsx
+++ b/src/components/Orderbook.tsx
@@ -13,21 +13,13 @@ import { useSettings } from '../context/SettingsContext'
 import Balance from './Balance'
 import Sprite from './Sprite'
 import TablePagination from './TablePagination'
+import { factorToPercentage, isAbsoluteOffer, isRelativeOffer } from '../utils'
+import { isDevMode } from '../constants/debugFeatures'
 import styles from './Orderbook.module.css'
 
-const SORT_KEYS = {
-  type: 'TYPE',
-  counterparty: 'COUNTERPARTY',
-  fee: 'FEE',
-  minimumSize: 'MINIMUM_SIZE',
-  maximumSize: 'MAXIMUM_SIZE',
-  minerFeeContribution: 'MINER_FEE_CONTRIBUTION',
-  bondValue: 'BOND_VALUE',
-}
-
 const TABLE_THEME = {
   Table: `
-    --data-table-library_grid-template-columns: 1fr 5rem 1fr 1fr 2fr 2fr 2fr 2fr;
+    --data-table-library_grid-template-columns: 1fr 5rem 1fr 2fr 2fr 2fr 2fr;
     font-size: 0.9rem;
   `,
   BaseCell: `
@@ -78,27 +70,89 @@ const TABLE_THEME = {
   `,
 }
 
-const withTooltip = (node: ReactElement, tooltip: string) => {
+const withTooltip = (node: ReactElement, tooltip: string, overlayProps?: Partial<rb.OverlayTriggerProps>) => {
   return (
-    <rb.OverlayTrigger overlay={(props) => <rb.Tooltip {...props}>{tooltip}</rb.Tooltip>}>{node}</rb.OverlayTrigger>
+    <rb.OverlayTrigger {...overlayProps} overlay={(props) => <rb.Tooltip {...props}>{tooltip}</rb.Tooltip>}>
+      {node}
+    </rb.OverlayTrigger>
   )
 }
 
-// `TableNode` is known to have same properties as `ObwatchApi.Order`, hence prefer casting over object destructuring
-const toOrder = (tableNode: TableTypes.TableNode) => tableNode as unknown as ObwatchApi.Order
+// `TableNode` is known to have same properties as `OrderTableEntry`, hence prefer casting over object destructuring
+const asOrderTableEntry = (tableNode: TableTypes.TableNode) => tableNode as unknown as OrderTableEntry
+
+const renderOrderType = (type: OrderTypeProps) => {
+  const elem = <rb.Badge bg={type.badgeColor}>{type.displayValue}</rb.Badge>
+  return type.tooltip ? withTooltip(elem, type.tooltip) : elem
+}
 
-const renderOrderType = (val: string, t: TFunction) => {
-  if (val === ObwatchApi.ABSOLUTE_ORDER_TYPE_VAL) {
-    return withTooltip(<rb.Badge bg="info">{t('orderbook.text_offer_type_absolute')}</rb.Badge>, val)
+type OrderTypeProps = {
+  value: string // original value, example: 'sw0reloffer', 'swreloffer', 'reloffer', 'sw0absoffer', 'swabsoffer', 'absoffer'
+  displayValue: string // example: "absolute" or "relative" (respecting i18n)
+  badgeColor: 'info' | 'primary' | 'secondary'
+  tooltip?: 'Native SW Absolute Fee' | 'Native SW Relative Fee' | string
+  isAbsolute?: boolean
+  isRelative?: boolean
+}
+interface OrderTableEntry {
+  type: OrderTypeProps
+  counterparty: string // example: "J5Bv3JSxPFWm2Yjb"
+  orderId: string // example: "0" (not unique!)
+  fee: {
+    value: number
+    displayValue: string // example: "250" (abs offers) or "0.000100%" (rel offers)
   }
-  if (val === ObwatchApi.RELATIVE_ORDER_TYPE_VAL) {
-    return withTooltip(<rb.Badge bg="primary">{t('orderbook.text_offer_type_relative')}</rb.Badge>, val)
+  minerFeeContribution: string // example: "0"
+  minimumSize: string // example: "27300"
+  maximumSize: string // example: "237499972700"
+  bondValue: {
+    value: number
+    displayValue: string // example: "0" (no fb) or "114557102085.28133"
+  }
+}
+
+const SORT_KEYS = {
+  type: 'TYPE',
+  counterparty: 'COUNTERPARTY',
+  fee: 'FEE',
+  minimumSize: 'MINIMUM_SIZE',
+  maximumSize: 'MAXIMUM_SIZE',
+  minerFeeContribution: 'MINER_FEE_CONTRIBUTION',
+  bondValue: 'BOND_VALUE',
+}
+
+const orderTypeProps = (offer: ObwatchApi.Offer, t: TFunction): OrderTypeProps => {
+  if (isAbsoluteOffer(offer.ordertype)) {
+    return {
+      value: offer.ordertype,
+      displayValue: t('orderbook.text_offer_type_absolute'),
+      badgeColor: 'info',
+      tooltip: offer.ordertype === 'sw0absoffer' ? 'Native SW Absolute Fee' : offer.ordertype,
+      isAbsolute: true,
+    }
+  }
+  if (isRelativeOffer(offer.ordertype)) {
+    return {
+      value: offer.ordertype,
+      displayValue: t('orderbook.text_offer_type_relative'),
+      badgeColor: 'primary',
+      tooltip: offer.ordertype === 'sw0reloffer' ? 'Native SW Relative Fee' : offer.ordertype,
+      isRelative: true,
+    }
+  }
+  return {
+    value: offer.ordertype,
+    displayValue: offer.ordertype,
+    badgeColor: 'secondary',
   }
-  return <rb.Badge bg="secondary">{val}</rb.Badge>
 }
 
 const renderOrderFee = (val: string, settings: any) => {
-  return val.includes('%') ? <>{val}</> : <Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />
+  return val.includes('%') ? (
+    <span className="font-monospace">{val}</span>
+  ) : (
+    <Balance valueString={val} convertToUnit={settings.unit} showBalance={true} />
+  )
 }
 
 interface OrderbookTableProps {
@@ -134,39 +188,28 @@ const OrderbookTable = ({ data }: OrderbookTableProps) => {
       },
       sortToggleType: SortToggleType.AlternateWithReset,
       sortFns: {
-        [SORT_KEYS.type]: (array) => array.sort((a, b) => a.type.localeCompare(b.type)),
+        [SORT_KEYS.type]: (array) => array.sort((a, b) => a.type.displayValue.localeCompare(b.type.displayValue)),
         [SORT_KEYS.fee]: (array) =>
           array.sort((a, b) => {
-            const aOrder = toOrder(a)
-            const bOrder = toOrder(b)
-
-            if (aOrder.type !== bOrder.type) {
-              return aOrder.type === ObwatchApi.ABSOLUTE_ORDER_TYPE_VAL ? 1 : -1
-            }
-
-            if (aOrder.type === ObwatchApi.ABSOLUTE_ORDER_TYPE_VAL) {
-              return +aOrder.fee - +bOrder.fee
-            } else {
-              const aIndexOfPercent = aOrder.fee.indexOf('%')
-              const bIndexOfPercent = bOrder.fee.indexOf('%')
+            const aOrder = asOrderTableEntry(a)
+            const bOrder = asOrderTableEntry(b)
 
-              if (aIndexOfPercent > 0 && bIndexOfPercent > 0) {
-                return +aOrder.fee.substring(0, aIndexOfPercent) - +bOrder.fee.substring(0, bIndexOfPercent)
-              }
+            if (aOrder.type.isAbsolute !== bOrder.type.isAbsolute) {
+              return aOrder.type.isAbsolute === true ? 1 : -1
             }
-
-            return 0
+            return aOrder.fee.value - bOrder.fee.value
           }),
         [SORT_KEYS.minimumSize]: (array) => array.sort((a, b) => a.minimumSize - b.minimumSize),
         [SORT_KEYS.maximumSize]: (array) => array.sort((a, b) => a.maximumSize - b.maximumSize),
         [SORT_KEYS.minerFeeContribution]: (array) =>
           array.sort((a, b) => a.minerFeeContribution - b.minerFeeContribution),
+
         [SORT_KEYS.counterparty]: (array) =>
           array.sort((a, b) => {
             const val = a.counterparty.localeCompare(b.counterparty)
             return val !== 0 ? val : +a.orderId - +b.orderId
           }),
-        [SORT_KEYS.bondValue]: (array) => array.sort((a, b) => a.bondValue - b.bondValue),
+        [SORT_KEYS.bondValue]: (array) => array.sort((a, b) => a.bondValue.value - b.bondValue.value),
       },
     },
   )
@@ -197,7 +240,7 @@ const OrderbookTable = ({ data }: OrderbookTableProps) => {
                 <HeaderCellSort sortKey={SORT_KEYS.maximumSize}>
                   {t('orderbook.table.heading_maximum_size')}
                 </HeaderCellSort>
-                <HeaderCellSort sortKey={SORT_KEYS.minerFeeContribution}>
+                <HeaderCellSort hide={true} sortKey={SORT_KEYS.minerFeeContribution}>
                   {t('orderbook.table.heading_miner_fee_contribution')}
                 </HeaderCellSort>
                 <HeaderCellSort sortKey={SORT_KEYS.bondValue}>{t('orderbook.table.heading_bond_value')}</HeaderCellSort>
@@ -205,27 +248,27 @@ const OrderbookTable = ({ data }: OrderbookTableProps) => {
             </Header>
             <Body>
               {tableList.map((item) => {
-                const order = toOrder(item)
+                const order = asOrderTableEntry(item)
                 return (
                   <Row key={item.id} item={item} className={item._highlighted ? styles.highlighted : ''}>
-                    <Cell>{order.counterparty}</Cell>
+                    <Cell className="font-monospace">{order.counterparty}</Cell>
                     <Cell>{order.orderId}</Cell>
-                    <Cell>{renderOrderType(order.type, t)}</Cell>
-                    <Cell>{renderOrderFee(order.fee, settings)}</Cell>
+                    <Cell>{renderOrderType(order.type)}</Cell>
+                    <Cell>{renderOrderFee(order.fee.displayValue, settings)}</Cell>
                     <Cell>
                       <Balance valueString={order.minimumSize} convertToUnit={settings.unit} showBalance={true} />
                     </Cell>
                     <Cell>
                       <Balance valueString={order.maximumSize} convertToUnit={settings.unit} showBalance={true} />
                     </Cell>
-                    <Cell>
+                    <Cell hide={true}>
                       <Balance
                         valueString={order.minerFeeContribution}
                         convertToUnit={settings.unit}
                         showBalance={true}
                       />
                     </Cell>
-                    <Cell>{order.bondValue}</Cell>
+                    <Cell className="font-monospace">{order.bondValue.displayValue}</Cell>
                   </Row>
                 )
               })}
@@ -240,35 +283,63 @@ const OrderbookTable = ({ data }: OrderbookTableProps) => {
   )
 }
 
+const offerToTableEntry = (offer: ObwatchApi.Offer, t: TFunction): OrderTableEntry => {
+  return {
+    type: orderTypeProps(offer, t),
+    counterparty: offer.counterparty,
+    orderId: String(offer.oid),
+    fee:
+      typeof offer.cjfee === 'number'
+        ? {
+            value: offer.cjfee,
+            displayValue: String(offer.cjfee),
+          }
+        : (() => {
+            const value = parseFloat(offer.cjfee)
+            return {
+              value,
+              displayValue: factorToPercentage(value).toFixed(4) + '%',
+            }
+          })(),
+    minerFeeContribution: String(offer.txfee),
+    minimumSize: String(offer.minsize),
+    maximumSize: String(offer.maxsize),
+    bondValue: {
+      value: offer.fidelity_bond_value,
+      displayValue: String(offer.fidelity_bond_value.toFixed(0)),
+    },
+  }
+}
+
 interface OrderbookProps {
-  orders: ObwatchApi.Order[]
+  entries: OrderTableEntry[]
   refresh: (signal: AbortSignal) => Promise<void>
   nickname?: string
 }
 
-export function Orderbook({ orders, refresh, nickname }: OrderbookProps) {
+export function Orderbook({ entries, refresh, nickname }: OrderbookProps) {
   const { t } = useTranslation()
   const settings = useSettings()
   const [search, setSearch] = useState('')
   const [isLoadingRefresh, setIsLoadingRefresh] = useState(false)
   const [isHighlightOwnOffers, setIsHighlightOwnOffers] = useState(false)
-  const [highlightedOrders, setHighlightedOrders] = useState<ObwatchApi.Order[]>([])
+  const [highlightedOrders, setHighlightedOrders] = useState<OrderTableEntry[]>([])
 
   const tableData: TableTypes.Data = useMemo(() => {
     const searchVal = search.replace('.', '').toLowerCase()
     const filteredOrders =
       searchVal === ''
-        ? orders
-        : orders.filter((order) => {
+        ? entries
+        : entries.filter((entry) => {
             return (
-              order.type.toLowerCase().includes(searchVal) ||
-              order.counterparty.toLowerCase().includes(searchVal) ||
-              order.fee.replace('.', '').toLowerCase().includes(searchVal) ||
-              order.minimumSize.replace('.', '').toLowerCase().includes(searchVal) ||
-              order.maximumSize.replace('.', '').toLowerCase().includes(searchVal) ||
-              order.minerFeeContribution.replace('.', '').toLowerCase().includes(searchVal) ||
-              order.bondValue.replace('.', '').toLowerCase().includes(searchVal) ||
-              order.orderId.toLowerCase().includes(searchVal)
+              entry.type.displayValue.toLowerCase().includes(searchVal) ||
+              entry.counterparty.toLowerCase().includes(searchVal) ||
+              entry.fee.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
+              entry.minimumSize.replace('.', '').toLowerCase().includes(searchVal) ||
+              entry.maximumSize.replace('.', '').toLowerCase().includes(searchVal) ||
+              entry.minerFeeContribution.replace('.', '').toLowerCase().includes(searchVal) ||
+              entry.bondValue.displayValue.replace('.', '').toLowerCase().includes(searchVal) ||
+              entry.orderId.toLowerCase().includes(searchVal)
             )
           })
     const nodes = filteredOrders.map((order) => ({
@@ -278,9 +349,9 @@ export function Orderbook({ orders, refresh, nickname }: OrderbookProps) {
     }))
 
     return { nodes }
-  }, [orders, search, highlightedOrders])
+  }, [entries, search, highlightedOrders])
 
-  const counterpartyCount = useMemo(() => new Set(orders.map((it) => it.counterparty)).size, [orders])
+  const counterpartyCount = useMemo(() => new Set(entries.map((it) => it.counterparty)).size, [entries])
   const counterpartyCountFiltered = useMemo(
     () => new Set(tableData.nodes.map((it) => it.counterparty)).size,
     [tableData],
@@ -290,9 +361,9 @@ export function Orderbook({ orders, refresh, nickname }: OrderbookProps) {
     if (!nickname || !isHighlightOwnOffers) {
       setHighlightedOrders([])
     } else {
-      setHighlightedOrders(orders.filter((it) => it.counterparty === nickname))
+      setHighlightedOrders(entries.filter((it) => it.counterparty === nickname))
     }
-  }, [orders, nickname, isHighlightOwnOffers])
+  }, [entries, nickname, isHighlightOwnOffers])
 
   return (
     <div className={styles.orderbookContainer}>
@@ -323,7 +394,7 @@ export function Orderbook({ orders, refresh, nickname }: OrderbookProps) {
             {search === '' ? (
               <>
                 {t('orderbook.text_orderbook_summary', {
-                  count: orders.length,
+                  count: entries.length,
                   counterpartyCount,
                 })}
               </>
@@ -352,7 +423,7 @@ export function Orderbook({ orders, refresh, nickname }: OrderbookProps) {
       </div>
 
       <div className="px-md-3 pb-2">
-        {orders.length === 0 ? (
+        {entries.length === 0 ? (
           <rb.Alert variant="info">{t('orderbook.alert_empty_orderbook')}</rb.Alert>
         ) : (
           <>
@@ -385,7 +456,8 @@ export function OrderbookOverlay({ nickname, show, onHide }: OrderbookOverlayPro
   const [alert, setAlert] = useState<SimpleAlert>()
   const [isInitialized, setIsInitialized] = useState(false)
   const [isLoading, setIsLoading] = useState(true)
-  const [orders, setOrders] = useState<ObwatchApi.Order[] | null>(null)
+  const [offers, setOffers] = useState<ObwatchApi.Offer[]>()
+  const tableEntries = useMemo(() => offers && offers.map((offer) => offerToTableEntry(offer, t)), [offers, t])
 
   const refresh = useCallback(
     (signal: AbortSignal) => {
@@ -398,11 +470,15 @@ export function OrderbookOverlay({ nickname, show, onHide }: OrderbookOverlayPro
 
           return ObwatchApi.fetchOrderbook({ signal })
         })
-        .then((orders) => {
+        .then((orderbook) => {
           if (signal.aborted) return
 
-          setOrders(orders)
           setAlert(undefined)
+          setOffers(orderbook.offers || [])
+
+          if (isDevMode()) {
+            console.table(orderbook.offers)
+          }
         })
         .catch((e) => {
           if (signal.aborted) return
@@ -461,17 +537,17 @@ export function OrderbookOverlay({ nickname, show, onHide }: OrderbookOverlayPro
               .map((_, index) => {
                 return (
                   <rb.Placeholder key={index} as="div" animation="wave">
-                    <rb.Placeholder xs={12} className={styles['orderbook-line-placeholder']} />
+                    <rb.Placeholder xs={12} className={styles.orderbookContentPlaceholder} />
                   </rb.Placeholder>
                 )
               })
           ) : (
             <>
               {alert && <rb.Alert variant={alert.variant}>{alert.message}</rb.Alert>}
-              {orders && (
+              {tableEntries && (
                 <rb.Row>
                   <rb.Col className="px-0">
-                    <Orderbook nickname={nickname} orders={orders} refresh={refresh} />
+                    <Orderbook nickname={nickname} entries={tableEntries} refresh={refresh} />
                   </rb.Col>
                 </rb.Row>
               )}
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
index 0577b54..d9801e6 100644
--- a/src/components/Settings.tsx
+++ b/src/components/Settings.tsx
@@ -16,7 +16,7 @@ import languages from '../i18n/languages'
 import styles from './Settings.module.css'
 import SeedModal from './settings/SeedModal'
 import FeeConfigModal from './settings/FeeConfigModal'
-import { isDebugFeatureEnabled } from '../constants/debugFeatures'
+import { isDebugFeatureEnabled, isDevMode } from '../constants/debugFeatures'
 import { isFeatureEnabled } from '../constants/features'
 import { CurrentWallet } from '../context/WalletContext'
 
@@ -86,11 +86,14 @@ export default function Settings({ wallet, stopWallet }: SettingsProps) {
       .then((data) => data && data.features)
       .then((features) => {
         if (abortCtrl.signal.aborted) return
-        setShowLogsEnabled(features && features.logs === true)
+        const hasLogsFeatureOld = features && features.logs === true
+        const hasLogsFeature =
+          features && Array.isArray(features) && features.some((it) => it.name === 'logs' && it.enabled === true)
+        setShowLogsEnabled(hasLogsFeatureOld || hasLogsFeature)
       })
       .catch((_) => {
         if (abortCtrl.signal.aborted) return
-        setShowLogsEnabled(false)
+        setShowLogsEnabled(isDevMode())
       })
 
     return () => {
@@ -179,15 +182,6 @@ export default function Settings({ wallet, stopWallet }: SettingsProps) {
         </rb.Button>
         {showingFeeConfig && <FeeConfigModal show={showingFeeConfig} onHide={() => setShowingFeeConfig(false)} />}
 
-        {showLogsEnabled && (
-          <>
-            <rb.Button variant="outline-dark" className={styles['settings-btn']} onClick={() => setShowingLogs(true)}>
-              <Sprite symbol="console" width="24" height="24" />
-              {t('settings.show_logs')}
-            </rb.Button>
-            <LogOverlay currentWallet={wallet} show={showingLogs} onHide={() => setShowingLogs(false)} />
-          </>
-        )}
         <div className={styles['section-title']}>{t('settings.section_title_wallet')}</div>
         <div className={styles['settings-group-container']}>
           <rb.Button variant="outline-dark" className={styles['settings-btn']} onClick={(e) => setShowingSeed(true)}>
@@ -230,6 +224,16 @@ export default function Settings({ wallet, stopWallet }: SettingsProps) {
               </span>
             </Link>
           )}
+
+          {showLogsEnabled && (
+            <>
+              <rb.Button variant="outline-dark" className={styles['settings-btn']} onClick={() => setShowingLogs(true)}>
+                <Sprite symbol="console" width="24" height="24" />
+                {t('settings.show_logs')}
+              </rb.Button>
+              <LogOverlay currentWallet={wallet} show={showingLogs} onHide={() => setShowingLogs(false)} />
+            </>
+          )}
         </div>
 
         <div className={styles['section-title']}>{t('settings.section_title_community')}</div>
diff --git a/src/components/WalletCreationForm.tsx b/src/components/WalletCreationForm.tsx
index baf3441..db7ba6e 100644
--- a/src/components/WalletCreationForm.tsx
+++ b/src/components/WalletCreationForm.tsx
@@ -3,7 +3,7 @@ import * as rb from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { Formik, FormikErrors } from 'formik'
 import Sprite from './Sprite'
-import { sanitizeWalletName } from '../utils'
+import { JM_WALLET_FILE_EXTENSION, sanitizeWalletName } from '../utils'
 import styles from './WalletCreationForm.module.css'
 
 export interface CreateWalletFormValues {
@@ -20,6 +20,9 @@ const initialCreateWalletFormValues: CreateWalletFormValues = {
 
 export type WalletNameAndPassword = { name: string; password: string }
 
+const MAX_WALLET_NAME_LENGTH = 240 - JM_WALLET_FILE_EXTENSION.length
+const validateWalletName = (input: string) => input.length <= MAX_WALLET_NAME_LENGTH && /^[\w-]+$/.test(input)
+
 interface WalletCreationFormProps {
   initialValues?: CreateWalletFormValues
   submitButtonText: (isSubmitting: boolean) => React.ReactNode | string
@@ -38,7 +41,7 @@ const WalletCreationForm = ({
   const validate = useCallback(
     (values: CreateWalletFormValues) => {
       const errors = {} as FormikErrors<CreateWalletFormValues>
-      if (!values.walletName) {
+      if (!values.walletName || !validateWalletName(values.walletName)) {
         errors.walletName = t('create_wallet.feedback_invalid_wallet_name')
       }
       if (!values.password) {
@@ -75,6 +78,7 @@ const WalletCreationForm = ({
               isValid={touched.walletName && !errors.walletName}
               isInvalid={touched.walletName && !!errors.walletName}
               className={styles.input}
+              maxLength={MAX_WALLET_NAME_LENGTH}
             />
             <rb.Form.Control.Feedback>{t('create_wallet.feedback_valid')}</rb.Form.Control.Feedback>
             <rb.Form.Control.Feedback type="invalid">{errors.walletName}</rb.Form.Control.Feedback>
diff --git a/src/components/jar_details/DisplayBranch.tsx b/src/components/jar_details/DisplayBranch.tsx
index 65da72b..e84b4f9 100644
--- a/src/components/jar_details/DisplayBranch.tsx
+++ b/src/components/jar_details/DisplayBranch.tsx
@@ -4,9 +4,9 @@ import { useTranslation } from 'react-i18next'
 import Balance from '../Balance'
 import { useSettings } from '../../context/SettingsContext'
 import { Branch, BranchEntry } from '../../context/WalletContext'
-import styles from './DisplayBranch.module.css'
 import { CopyButton } from '../CopyButton'
 import Sprite from '../Sprite'
+import styles from './DisplayBranch.module.css'
 
 const toHdPathIndex = (hdPath: string) => {
   const indexOfLastSeparator = hdPath.lastIndexOf('/')
@@ -63,42 +63,19 @@ export function DisplayBranchHeader({ branch }: DisplayBranchProps) {
 }
 
 export function DisplayBranchBody({ branch }: DisplayBranchProps) {
-  const { branch: detailsString, entries } = branch
-  const xpub: string | undefined = detailsString.split('\t')[2]
   return (
     <rb.Container className="mb-2" fluid>
-      <rb.Row>
-        <rb.Col>
-          {xpub && (
-            <div className="p-3 mb-1">
-              <div>Extended public key</div>
-              <code className="text-secondary text-break">{xpub}</code>
-            </div>
-          )}
-        </rb.Col>
-      </rb.Row>
-      {entries.map((entry, index) => (
+      {branch.entries.map((entry) => (
         <DisplayBranchEntry
           key={entry.address}
           entry={entry}
-          className={`bg-transparent rounded-0 border-start-0 border-end-0 ${
-            index === 0 ? 'border-top-1 mt-2' : 'border-top-0'
-          }`}
+          className="bg-transparent rounded-0 border-start-0 border-end-0 border-bottom-0"
         />
       ))}
     </rb.Container>
   )
 }
 
-export default function DisplayBranch({ branch }: DisplayBranchProps) {
-  return (
-    <article>
-      <DisplayBranchHeader branch={branch} />
-      <DisplayBranchBody branch={branch} />
-    </article>
-  )
-}
-
 interface DisplayBranchEntryProps extends rb.CardProps {
   entry: BranchEntry
 }
diff --git a/src/components/jar_details/JarDetailsOverlay.tsx b/src/components/jar_details/JarDetailsOverlay.tsx
index b4d74ea..5f122e2 100644
--- a/src/components/jar_details/JarDetailsOverlay.tsx
+++ b/src/components/jar_details/JarDetailsOverlay.tsx
@@ -414,16 +414,18 @@ const JarDetailsOverlay = (props: JarDetailsOverlayProps) => {
                   </>
                 ) : (
                   <rb.Accordion flush className="p-3 p-lg-0">
-                    {jar.branches.map((branch, index) => (
-                      <rb.Accordion.Item className={styles.jarItem} key={branch.branch} eventKey={`${index}`}>
-                        <rb.Accordion.Header>
-                          <DisplayBranchHeader branch={branch} />
-                        </rb.Accordion.Header>
-                        <rb.Accordion.Body>
-                          <DisplayBranchBody branch={branch} />
-                        </rb.Accordion.Body>
-                      </rb.Accordion.Item>
-                    ))}
+                    {jar.branches
+                      .filter((it) => it.entries.length > 0)
+                      .map((branch, index) => (
+                        <rb.Accordion.Item className={styles.jarItem} key={branch.branch} eventKey={`${index}`}>
+                          <rb.Accordion.Header>
+                            <DisplayBranchHeader branch={branch} />
+                          </rb.Accordion.Header>
+                          <rb.Accordion.Body>
+                            <DisplayBranchBody branch={branch} />
+                          </rb.Accordion.Body>
+                        </rb.Accordion.Item>
+                      ))}
                   </rb.Accordion>
                 )}
               </div>
diff --git a/src/constants/debugFeatures.ts b/src/constants/debugFeatures.ts
index 97e3ab7..43f9cfd 100644
--- a/src/constants/debugFeatures.ts
+++ b/src/constants/debugFeatures.ts
@@ -3,6 +3,7 @@ interface DebugFeatures {
   allowCreatingExpiredFidelityBond: boolean
   skipWalletBackupConfirmation: boolean
   errorExamplePage: boolean
+  devSetupPage: boolean
   importDummyMnemonicPhrase: boolean
   rescanChainPage: boolean
   fastThemeToggle: boolean
@@ -15,6 +16,7 @@ const debugFeatures: DebugFeatures = {
   insecureScheduleTesting: devMode,
   skipWalletBackupConfirmation: devMode,
   errorExamplePage: devMode,
+  devSetupPage: devMode,
   importDummyMnemonicPhrase: devMode,
   rescanChainPage: devMode,
   fastThemeToggle: devMode,
diff --git a/src/constants/routes.ts b/src/constants/routes.ts
index 68a2020..62b78a9 100644
--- a/src/constants/routes.ts
+++ b/src/constants/routes.ts
@@ -11,6 +11,7 @@ export const routes = {
   importWallet: '/import-wallet',
   rescanChain: '/rescan',
   __errorExample: '/error-example',
+  __devSetup: '/dev-setup',
 }
 
 export type Route = keyof typeof routes
diff --git a/src/context/ServiceInfoContext.tsx b/src/context/ServiceInfoContext.tsx
index 219fbf7..87a97d3 100644
--- a/src/context/ServiceInfoContext.tsx
+++ b/src/context/ServiceInfoContext.tsx
@@ -208,7 +208,7 @@ const ServiceInfoProvider = ({ children }: PropsWithChildren<{}>) => {
         })
         .catch((err) => {
           if (!signal.aborted) {
-            const isUnauthorized = err instanceof Api.JmApiError && err.response.status === 401
+            const isUnauthorized = err instanceof Api.JmApiError && err.response?.status === 401
             if (isUnauthorized) {
               resetWalletAndClearSession()
             } else {
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 4f50da2..b01bc72 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -37,6 +37,7 @@
     "title": "Jam",
     "text_rescan_in_progress": "Rescanning...",
     "button_create_wallet": "Create Wallet",
+    "button_import_wallet": "Import Wallet",
     "tab_send": "Send",
     "tab_receive": "Receive",
     "tab_earn": "Earn",
@@ -125,7 +126,7 @@
     "feedback_valid": "Looks good!",
     "label_wallet_name": "Wallet name",
     "placeholder_wallet_name": "Your Wallet...",
-    "feedback_invalid_wallet_name": "Please set a wallet name.",
+    "feedback_invalid_wallet_name": "Please choose a valid wallet name: Use only letters, numbers, underscores or hyphens.",
     "label_password": "Password to unlock the wallet",
     "placeholder_password": "Choose a secure password...",
     "feedback_invalid_password": "Please set a password.",
diff --git a/src/index.css b/src/index.css
index 6316f9a..ef2b624 100644
--- a/src/index.css
+++ b/src/index.css
@@ -573,10 +573,6 @@ h2 {
 }
 /* Alpha Warning */
 
-.warning-hint {
-  font-size: 0.8rem;
-}
-
 .warning-card-wrapper {
   position: fixed;
   height: 100%;
@@ -726,6 +722,7 @@ h2 {
 
 :root[data-theme='dark'] .link-dark {
   color: var(--bs-white) !important;
+  text-decoration-color: RGBA(var(--bs-white), var(--bs-link-underline-opacity, 1)) !important;
 }
 
 :root[data-theme='dark'] .toast {
diff --git a/src/libs/JmObwatchApi.ts b/src/libs/JmObwatchApi.ts
index 6cff3e4..23d0098 100644
--- a/src/libs/JmObwatchApi.ts
+++ b/src/libs/JmObwatchApi.ts
@@ -1,76 +1,30 @@
-import { Helper as ApiHelper } from '../libs/JmWalletApi'
+import { AmountSats, Helper as ApiHelper } from '../libs/JmWalletApi'
 
 const basePath = () => `${window.JM.PUBLIC_PATH}/obwatch`
 
-export const ABSOLUTE_ORDER_TYPE_VAL = 'Native SW Absolute Fee'
-export const RELATIVE_ORDER_TYPE_VAL = 'Native SW Relative Fee'
-
-export interface Order {
-  type: string // example: "Native SW Absolute Fee" or "Native SW Relative Fee"
+export interface Offer {
   counterparty: string // example: "J5Bv3JSxPFWm2Yjb"
-  orderId: string // example: "0" (not unique!)
-  fee: string // example: "0.00000250" (abs offers) or "0.000100%" (rel offers)
-  minerFeeContribution: string // example: "0.00000000"
-  minimumSize: string // example: "0.00027300"
-  maximumSize: string // example: "2374.99972700"
-  bondValue: string // example: "0" (no fb) or "0.0000052877962973"
+  oid: number // example: 0 (not unique!)
+  ordertype: string // example: "sw0absoffer" or "sw0reloffer"
+  minsize: AmountSats // example: 27300
+  maxsize: AmountSats // example: 237499972700
+  txfee: AmountSats // example: 0
+  cjfee: AmountSats | string // example: 250 (abs offers) or "0.00017" (rel offers)
+  fidelity_bond_value: number // example: 0 (no fb) or 114557102085.28133
 }
 
-const ORDER_KEYS: (keyof Order)[] = [
-  'type',
-  'counterparty',
-  'orderId',
-  'fee',
-  'minerFeeContribution',
-  'minimumSize',
-  'maximumSize',
-  'bondValue',
-]
-
-const parseOrderbook = (res: Response): Promise<Order[]> => {
-  if (!res.ok) {
-    // e.g. error is raised if ob-watcher is not running
-    return ApiHelper.throwError(res)
-  }
-
-  return res.text().then((html) => {
-    var parser = new DOMParser()
-    var doc = parser.parseFromString(html, 'text/html')
-
-    const tables = doc.getElementsByTagName('table')
-    if (tables.length !== 1) {
-      throw new Error('Cannot find orderbook table')
-    }
-    const orderbookTable = tables[0]
-    const tbodies = [...orderbookTable.children].filter((child) => child.tagName.toLowerCase() === 'tbody')
-    if (tbodies.length !== 1) {
-      throw new Error('Cannot find orderbook table body')
-    }
-
-    const tbody = tbodies[0]
-
-    const orders: Order[] = [...tbody.children]
-      .filter((row) => row.tagName.toLowerCase() === 'tr')
-      .filter((row) => row.children.length > 0)
-      .map((row) => [...row.children].filter((child) => child.tagName.toLowerCase() === 'td'))
-      .filter((cols) => cols.length === ORDER_KEYS.length)
-      .map((cols) => {
-        const data: unknown = ORDER_KEYS.map((key, index) => ({ [key]: cols[index].innerHTML })).reduce(
-          (acc, curr) => ({ ...acc, ...curr }),
-          {},
-        )
-        return data as Order
-      })
+export interface OrderbookJson {
+  offers?: Offer[]
+}
 
-    return orders
+const orderbookJson = async ({ signal }: { signal: AbortSignal }) => {
+  return await fetch(`${basePath()}/orderbook.json`, {
+    signal,
   })
 }
 
-// TODO: why is "orderbook.json" always empty? -> Parse HTML in the meantime.. ¯\_(ツ)_/¯
-const fetchOrderbook = async ({ signal }: { signal: AbortSignal }) => {
-  return await fetch(`${basePath()}/`, {
-    signal,
-  }).then((res) => parseOrderbook(res))
+const fetchOrderbook = async (options: { signal: AbortSignal }): Promise<OrderbookJson> => {
+  return orderbookJson(options).then((res) => (res.ok ? res.json() : ApiHelper.throwError(res)))
 }
 
 const refreshOrderbook = async ({ signal }: { signal: AbortSignal }) => {
diff --git a/src/utils.ts b/src/utils.ts
index 9c68b98..5f53de0 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,4 +1,4 @@
-import { WalletFileName } from './libs/JmWalletApi'
+import { OfferType, WalletFileName } from './libs/JmWalletApi'
 
 const BTC_FORMATTER = new Intl.NumberFormat('en-US', {
   minimumIntegerDigits: 1,
@@ -13,7 +13,7 @@ const SATS_FORMATTER = new Intl.NumberFormat('en-US', {
 export const BTC: Unit = 'BTC'
 export const SATS: Unit = 'sats'
 
-const JM_WALLET_FILE_EXTENSION = '.jmdat'
+export const JM_WALLET_FILE_EXTENSION = '.jmdat'
 
 export const DUMMY_MNEMONIC_PHRASE: MnemonicPhrase =
   'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'.split(' ')
@@ -74,6 +74,12 @@ export const factorToPercentage = (val: number, precision = 6) => {
   return Number((val * 100).toFixed(precision))
 }
 
+// can be any of ['sw0reloffer', 'swreloffer', 'reloffer']
+export const isRelativeOffer = (offertype: OfferType) => offertype.includes('reloffer')
+
+// can be any of ['sw0absoffer', 'swabsoffer', 'absoffer']
+export const isAbsoluteOffer = (offertype: OfferType) => offertype.includes('absoffer')
+
 export const isValidNumber = (val: number | undefined) => typeof val === 'number' && !isNaN(val)
 
 export const UNKNOWN_VERSION: SemVer = { major: 0, minor: 0, patch: 0, raw: 'unknown' }


Test patch:
diff --git a/docker/regtest/docker-compose.yml b/docker/regtest/docker-compose.yml
index 3af8e43..e6c634e 100644
--- a/docker/regtest/docker-compose.yml
+++ b/docker/regtest/docker-compose.yml
@@ -175,6 +175,10 @@ services:
       BTCEXP_BITCOIND_PORT: 43782
       BTCEXP_BITCOIND_USER: regtest
       BTCEXP_BITCOIND_PASS: regtest
+      BTCEXP_BASIC_AUTH_PASSWORD: joinmarket
+      BTCEXP_PRIVACY_MODE: 'true'
+      BTCEXP_NO_RATES: 'true'
+      BTCEXP_RPC_ALLOWALL: 'true'
     ports:
       - "3002:3002"
     depends_on:
diff --git a/src/components/CreateWallet.test.tsx b/src/components/CreateWallet.test.tsx
index 6330dee..49c0eaf 100644
--- a/src/components/CreateWallet.test.tsx
+++ b/src/components/CreateWallet.test.tsx
@@ -19,7 +19,8 @@ jest.mock('../libs/JmWalletApi', () => ({
 const NOOP = () => {}
 
 describe('<CreateWallet />', () => {
-  const testWalletName = 'wallet'
+  const testWalletName = 'test_wallet21'
+  const invalidTestWalletName = 'invalid_wallet_name!'
   const testWalletPassword = 'correct horse battery staple'
 
   const setup = ({
@@ -56,12 +57,14 @@ describe('<CreateWallet />', () => {
   it('should show validation messages to user if form is invalid', async () => {
     act(() => setup({}))
 
+    expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_password')).not.toBeInTheDocument()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
     expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
 
     act(() => {
       // click on the "create" button without filling the form
-      const createWalletButton = screen.getByText('create_wallet.button_create')
-      user.click(createWalletButton)
+      user.click(screen.getByText('create_wallet.button_create'))
     })
 
     expect(await screen.findByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
@@ -69,11 +72,32 @@ describe('<CreateWallet />', () => {
     expect(await screen.findByText('create_wallet.feedback_invalid_password_confirm')).toBeVisible()
   })
 
+  it('should not submit form if wallet name contains invalid characters', async () => {
+    act(() => setup({}))
+
+    expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
+
+    act(() => {
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_wallet_name'), invalidTestWalletName)
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_password'), testWalletPassword)
+      user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), testWalletPassword)
+    })
+
+    act(() => user.click(screen.getByText('create_wallet.button_create')))
+
+    expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
+    expect(await screen.findByText('create_wallet.feedback_invalid_wallet_name')).toBeVisible()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
+  })
+
   it('should not submit form if passwords do not match', async () => {
     act(() => setup({}))
 
     expect(await screen.findByPlaceholderText('create_wallet.placeholder_password')).toBeVisible()
     expect(await screen.findByPlaceholderText('create_wallet.placeholder_password_confirm')).toBeVisible()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_password_confirm')).not.toBeInTheDocument()
     expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
 
     act(() => {
@@ -82,12 +106,10 @@ describe('<CreateWallet />', () => {
       user.type(screen.getByPlaceholderText('create_wallet.placeholder_password_confirm'), 'a_mismatching_input')
     })
 
-    act(() => {
-      const createWalletButton = screen.getByText('create_wallet.button_create')
-      user.click(createWalletButton)
-    })
+    act(() => user.click(screen.getByText('create_wallet.button_create')))
 
     expect(await screen.findByText('create_wallet.button_create')).toBeVisible()
+    expect(await screen.queryByText('create_wallet.feedback_invalid_wallet_name')).not.toBeInTheDocument()
     expect(await screen.findByText('create_wallet.feedback_invalid_password_confirm')).toBeVisible()
   })
 
@@ -114,8 +136,7 @@ describe('<CreateWallet />', () => {
     })
 
     await act(async () => {
-      const createWalletButton = screen.getByText('create_wallet.button_create')
-      user.click(createWalletButton)
+      user.click(screen.getByText('create_wallet.button_create'))
 
       await waitFor(() => screen.findByText(/create_wallet.button_creating/))
     })