diff --git a/src/App.tsx b/src/App.tsx index 4d16ed9..3ab4287 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { GeneratedBtcPage } from '@/pages/generated-btc/GeneratedBtcPage'; import { PayoutsPage } from '@/pages/payouts/PayoutsPage'; import { SettingsPage } from '@/pages/settings/SettingsPage'; import { HelpPage } from '@/pages/help/HelpPage'; +import { BuildYourBlockPage } from '@/pages/build-your-block/BuildYourBlockPage'; import { JobDeclarationPage } from '@/pages/build-your-block/JobDeclarationPage'; import { PrioritizeTransactionsPage } from '@/pages/build-your-block/PrioritizeTransactionsPage'; import { MergeMiningPage } from '@/pages/build-your-block/MergeMiningPage'; @@ -118,17 +119,22 @@ function AppRoutes() { - + + + + + + - + - + diff --git a/src/components/dashboard/Sidebar.tsx b/src/components/dashboard/Sidebar.tsx index bc018e5..e83fb82 100644 --- a/src/components/dashboard/Sidebar.tsx +++ b/src/components/dashboard/Sidebar.tsx @@ -9,7 +9,7 @@ import { TooltipPill } from '@/components/ui/tooltip-pill'; import { useAggregatedModeContext } from '@/hooks/AggregatedModeProvider'; import { useHasSubaccounts } from '@/hooks/useSubaccounts'; import { useAccountScope } from '@/hooks/useAccountScope'; -import { NAV_GROUPS, SETTINGS_ITEM, isSubaccountRestrictedRoute, type NavItem } from './nav'; +import { NAV_GROUPS, SETTINGS_ITEM, isPathActive, isSubaccountRestrictedRoute, type NavItem } from './nav'; import { AccountSwitcher } from './AccountSwitcher'; import { accountInitials } from './accountInitials'; @@ -26,14 +26,14 @@ function NavDropdown({ }) { const children = item.children ?? []; // Open while the reader is on one of its pages, so the trail back is always visible. - const [open, setOpen] = useState(() => children.some((c) => c.href === location)); + const [open, setOpen] = useState(() => children.some((c) => isPathActive(location, c.href))); const Icon = item.icon; if (collapsed) { - // The rail has no room for the label or the sub-items; the parent has no page of - // its own, so it links to its first child rather than nowhere. + // The rail has no room for the sub-items, but the parent now has a useful + // overview page. return ( - + - +
+ + + {item.label} + + +
{open && (
{children.map((child) => ( @@ -69,7 +70,7 @@ function NavDropdown({ // The drawn sub-row keeps an empty 20px slot where the parent's icon // sits, so the labels align in a single column. 'flex items-center gap-2 rounded-lg py-2 pl-[38px] pr-3 text-sm transition-colors', - location === child.href + isPathActive(location, child.href) ? 'bg-muted font-medium text-foreground' : 'text-body-alt hover:bg-muted hover:text-foreground', )} diff --git a/src/components/dashboard/nav.ts b/src/components/dashboard/nav.ts index 495e0d3..be7d2cc 100644 --- a/src/components/dashboard/nav.ts +++ b/src/components/dashboard/nav.ts @@ -120,7 +120,14 @@ const ALL_ITEMS = [ // Routes reachable outside the sidebar (top-bar actions) still need a title. const EXTRA_TITLES: Record = { '/help': 'Help & Support' }; +export function isPathActive(path: string, href: string): boolean { + return path === href || path.startsWith(`${href}/`); +} + /** The page title shown in the top bar for a given route. */ export function titleForPath(path: string): string { - return ALL_ITEMS.find((item) => item.href === path)?.label ?? EXTRA_TITLES[path] ?? 'Home'; + const owner = ALL_ITEMS.filter((item) => isPathActive(path, item.href)).sort( + (a, b) => b.href.length - a.href.length, + )[0]; + return owner?.label ?? EXTRA_TITLES[path] ?? 'Home'; } diff --git a/src/components/docs/DocGuide.tsx b/src/components/docs/DocGuide.tsx new file mode 100644 index 0000000..d6b0b76 --- /dev/null +++ b/src/components/docs/DocGuide.tsx @@ -0,0 +1,810 @@ +import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react'; +import { Link } from 'wouter'; +import { LiCopy, LiCheckCircle, LiAltArrowRight, LiAltArrowDown, LiCloseCircle, LiSettings } from 'solar-icon-react/li'; +import { OlArrowRightUp } from 'solar-icon-react/ol'; +import { cn } from '@/lib/utils'; +import { useAccountProfile } from '@/hooks/useAccountData'; +import { Chip, DocCallout, DocList, DocText } from '@/components/docs/DocPrimitives'; + + +interface DocCrumb { + label: string; + href?: string; +} + +/** The page wrapper. Pass trail instead of title when a guide has several pages. */ +export function DocPage({ + title, + trail, + children, +}: { + title?: string; + trail?: DocCrumb[]; + children: ReactNode; +}) { + // Opened from every page of the section, because the values it holds are the section's, + // not any one page's. + const [setupOpen, setSetupOpen] = useState(false); + // Nothing on the page says the commands can be personalised, so the button marks itself + // until it has been opened once. After that it is just another control. + const [seenSetup, setSeenSetup] = useState(() => { + try { + return window.localStorage.getItem(SETUP_SEEN) === '1'; + } catch { + return true; + } + }); + + const openSetup = () => { + setSetupOpen(true); + setSeenSetup(true); + try { + window.localStorage.setItem(SETUP_SEEN, '1'); + } catch { + /* the hint simply shows again next time */ + } + }; + + return ( +
+
+ {trail ? ( + + ) : ( +

{title}

+ )} + +
+ {setupOpen && setSetupOpen(false)} />} +
+
{children}
+
+ ); +} + +/** + * A copyable code block. `aligned` opts into the monospace family for blocks whose + * columns are space-padded and would otherwise render ragged. + */ +export function CodeBlock({ + code, + aligned = false, + className, +}: { + code: string; + aligned?: boolean; + className?: string; +}) { + const [copied, setCopied] = useState(false); + const [revealed, setRevealed] = useState(false); + // Whatever the reader has told the guide about their setup is substituted in here, so + // Copy hands over a command that runs rather than one they still have to edit. + const filled = useCommandFill(code); + const shown = revealed ? filled.copy : filled.display; + + const copy = () => { + void navigator.clipboard?.writeText(filled.copy); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+        {shown.split(/(<[^<>\s][^<>]*>)/g).map((part, i) =>
+          /^<[^<>\s][^<>]*>$/.test(part) ? (
+            
+              {part}
+            
+          ) : (
+            part
+          ),
+        )}
+      
+ + {/* Secrets are masked on screen but copied in full. */} + {filled.hasSecret && ( + <> + + + + )} + + + +
+ ); +} + +export function CommandLegend({ code }: { code: string }) { + const { filled, remaining } = useCommandFill(code); + if (!filled.length && !remaining.length) return null; + + return ( +
+ {filled.length > 0 && ( +

+ Filled in from your setup: + {filled.map((token) => ( + {token} + ))} +

+ )} + {remaining.length > 0 && ( +

+ + Replace before running: + {remaining.map((token) => ( + {token} + ))} +

+ )} +
+ ); +} + +/** One thing the reader needs, and everything needed to get it. */ +interface PrereqItem { + id: string; + name: ReactNode; + label?: string; + requirement?: ReactNode; + where?: { href: string; label: string; internal?: boolean }; + detail?: ReactNode; + /** Already satisfied, so the row reports instead of asking. */ + met?: boolean; + metNote?: string; + /** The guide walks through this one further on, so the row points there instead of + * leaving the reader to go and solve it first. */ + coveredNext?: boolean; +} + +const PREREQ_STORE = 'dmnd.guide.prereqs'; + +/** Readiness and step ticks. Stored locally: setting up a node spans days, not one sitting. */ +export function useGuideTicks(key: string) { + const [ticked, setTicked] = useState(() => { + try { + return JSON.parse(window.localStorage.getItem(`${PREREQ_STORE}.${key}`) ?? '[]') as string[]; + } catch { + return []; + } + }); + + useEffect(() => { + try { + window.localStorage.setItem(`${PREREQ_STORE}.${key}`, JSON.stringify(ticked)); + } catch { + /* blocked storage just means it is not remembered */ + } + }, [key, ticked]); + + const toggle = (id: string) => + setTicked((current) => (current.includes(id) ? current.filter((i) => i !== id) : [...current, id])); + + return { ticked, toggle }; +} + +function PrereqRow({ + item, + complete, + current, + last, + open, + coveredNextHref, + onToggle, + onOpen, +}: { + item: PrereqItem; + coveredNextHref?: string; + complete: boolean; + current: boolean; + last: boolean; + open: boolean; + onToggle: () => void; + onOpen: () => void; +}) { + const name = ( + + {item.name} + + ); + + return ( +
  • + + {item.met ? ( + + ) : ( + + )} + {!last && } + + + + + {item.detail ? ( + + ) : ( + name + )} + + + {item.requirement && ( + + {item.requirement} + + )} + {(item.met || item.where) && ( + + {item.met ? ( + {item.metNote} + ) : item.where?.internal ? ( + + {item.where.label} + + ) : item.where ? ( + + {item.where.label} + + + ) : null} + + )} + + + + {item.coveredNext && coveredNextHref && ( + + Covered in the next section + + + )} + {item.detail && open && {item.detail}} + +
  • + ); +} + +function DocPrereqs({ + items, + extra, + extraLabel, + ticked, + onToggle, + note, + coveredNextHref, + className, +}: { + items: PrereqItem[]; + /** Requirements one guide adds on top of the shared ones. */ + extra?: PrereqItem[]; + extraLabel?: ReactNode; + ticked: string[]; + onToggle: (id: string) => void; + note?: ReactNode; + /** Where rows marked `coveredNext` are dealt with. */ + coveredNextHref?: string; + className?: string; +}) { + const all = [...items, ...(extra ?? [])]; + const isDone = (item: PrereqItem) => item.met === true || ticked.includes(item.id); + const done = all.filter(isDone).length; + const currentId = all.find((item) => !isDone(item))?.id; + + // Opening is tracked as an override, so a reader's choice survives them ticking a row. + const [overrides, setOverrides] = useState>({}); + const isOpen = (item: PrereqItem) => overrides[item.id] ?? item.id === currentId; + + const rows = (group: PrereqItem[], lastOfAll: boolean) => + group.map((item, i) => ( + onToggle(item.id)} + onOpen={() => setOverrides((o) => ({ ...o, [item.id]: !isOpen(item) }))} + /> + )); + + const [open, setOpen] = useState(done < all.length); + + return ( +
    + + + {!open ? null : ( +
    +
      {rows(items, !extra?.length)}
    + + {extra?.length ? ( + <> + {extraLabel &&

    {extraLabel}

    } +
      {rows(extra, true)}
    + + ) : null} + + {note &&

    {note}

    } +
    + )} +
    + ); +} + +const SV2_TP_README = 'https://github.com/stratum-mining/sv2-tp#readme'; + +const DMND_CLIENT_SETUP = 'https://github.com/dmnd-pool/dmnd-client#4-run-the-dmnd-client'; + +const DMND_CLIENT_RELEASES = 'https://github.com/dmnd-pool/dmnd-client/releases'; + +const MINER_CONNECTION = 'stratum+tcp://:32767'; + +const linkButton = + 'inline-flex w-fit items-center gap-1 rounded-xl bg-background px-4 py-2 text-sm leading-5 text-foreground transition-opacity hover:opacity-80'; + +function useStackPrereqs() { + const { data: account } = useAccountProfile(); + const { ticked, toggle } = useGuideTicks('build-your-block'); + + const items: PrereqItem[] = [ + { + id: 'token', + name: 'DMND token', + met: Boolean(account?.token), + metNote: 'On your account', + where: { href: '/workers', label: 'Workers', internal: true }, + }, + { + id: 'node-and-tp', + name: 'Your node and Template Provider', + requirement: v31.0+, + detail: ( + <> + + You add two components to your DMND Client setup:{' '} + Bitcoin Core with IPC enabled, and the{' '} + Stratum V2 Template Provider (sv2-tp). + The Template Provider connects to Bitcoin Core through IPC and serves its templates to the DMND Client. + + + Set up Bitcoin Core and sv2-tp + + + + The Template Provider listens on port 8336 by default. + + + Bitcoin Core is fully synchronized, and the sv2-tp log shows a successful IPC connection and + new templates as blocks arrive. + + + ), + }, + { + id: 'dmnd-client', + name: 'DMND Client', + where: { href: DMND_CLIENT_RELEASES, label: 'Releases' }, + detail: ( + <> + + Run the DMND Client with your DMND token and the Template Provider address. It connects upstream to DMND, + receives your local templates, and exposes a standard Stratum V1 endpoint for your ASICs. + + + Continue with DMND Client setup + + + Once the client is healthy, point each ASIC at the machine running it: + + with the client machine’s LAN IP.', + 'Use your DMND token as the miner password.', + 'Use any worker name, or leave the username empty.', + ]} + /> + + The DMND Client log shows connections to both sv2-tp and DMND, with templates being declared. + Your miners connect normally and begin submitting shares. + + + ), + }, + ]; + + const note = ( + <> + Current sv2-tp releases require Bitcoin Core v31.0+; sv2-tp v1.0.6 is the + legacy release for Bitcoin Core v30.2. + + ); + + return { items, ticked, toggle, note, ready: items.every((i) => i.met === true || ticked.includes(i.id)) }; +} + +export function StackSummary({ href }: { href: string }) { + const stack = useStackPrereqs(); + const [open, setOpen] = useState(!stack.ready); + const done = stack.items.filter((i) => i.met === true || stack.ticked.includes(i.id)).length; + + return ( +
    +
    + {stack.ready ? ( + + ) : ( + + )} + + + {done}/{stack.items.length} Complete + + + Job Declaration + +
    + + {open && ( +
    + +
    + )} +
    + ); +} + +/** A readiness list a single guide owns, with its own ticks. */ +export function GuidePrereqs({ + items, + storageKey, + note, + coveredNextHref, + className, +}: { + items: PrereqItem[]; + storageKey: string; + note?: ReactNode; + coveredNextHref?: string; + className?: string; +}) { + const { ticked, toggle } = useGuideTicks(storageKey); + return ( + + ); +} + +/** Marks one step of a guide finished. Sits at the foot of the step it belongs to. */ +export function StepDone({ done, onToggle }: { done: boolean; onToggle: () => void }) { + return ( + + ); +} + +/** A value the reader supplies once and every command on every guide picks up. */ +interface SetupField { + id: string; + label: string; + /** What it replaces in the command text. */ + token: string; + hint?: string; +} + +const SETUP_FIELDS: Record = { + clientIp: { + id: 'clientIp', + label: 'DMND Client machine IP', + token: '', + hint: 'The LAN address your miners reach it on', + }, + tpAddress: { id: 'tpAddress', label: 'Template Provider address', token: '127.0.0.1:8336' }, + rskAddress: { id: 'rskAddress', label: 'RSK reward address', token: '' }, + rskRpcUrl: { id: 'rskRpcUrl', label: 'RskJ RPC URL', token: 'http://127.0.0.1:4444' }, + rpcUrl: { id: 'rpcUrl', label: 'Bitcoin Core RPC URL', token: 'http://127.0.0.1:8332' }, + rpcUser: { id: 'rpcUser', label: 'Bitcoin Core RPC user', token: '' }, +}; + +const SETUP_STORE = 'dmnd.guide.setup'; + +const SETUP_SEEN = 'dmnd.guide.setup.seen'; + +function loadSetupValues(): Record { + try { + return JSON.parse(window.localStorage.getItem(SETUP_STORE) ?? '{}') as Record; + } catch { + return {}; + } +} + +let setupValues: Record = loadSetupValues(); + +const setupListeners = new Set<() => void>(); + +function setSetupValue(id: string, value: string) { + setupValues = { ...setupValues, [id]: value }; + try { + const keep = Object.fromEntries(Object.entries(setupValues).filter(([, v]) => v !== '')); + window.localStorage.setItem(SETUP_STORE, JSON.stringify(keep)); + } catch { + /* blocked storage just means the values are not remembered */ + } + setupListeners.forEach((l) => l()); +} + +function useSetupValues() { + return useSyncExternalStore( + (listener) => { + setupListeners.add(listener); + return () => setupListeners.delete(listener); + }, + () => setupValues, + () => setupValues, + ); +} + +const MASK = '••••••••'; + +function useCommandFill(code: string) { + const values = useSetupValues(); + const { data: account } = useAccountProfile(); + const token = account?.token ?? ''; + + let display = code; + let copy = code; + let hasSecret = false; + const filled: string[] = []; + + const apply = (needle: string, value: string, secret: boolean) => { + if (!value || !code.includes(needle)) return; + copy = copy.split(needle).join(value); + display = display.split(needle).join(secret ? MASK : value); + if (secret) hasSecret = true; + filled.push(needle); + }; + + // The pool token is never asked for: the dashboard already has it for this account. + apply('', token, true); + for (const field of Object.values(SETUP_FIELDS)) { + apply(field.token, values[field.id] ?? '', false); + } + + // Whatever is still angle-bracketed is still the reader's to supply. + const remaining = [...new Set(display.match(/<[^<>\s][^<>]*>/g) ?? [])]; + + return { display, copy, hasSecret, filled, remaining }; +} + +function SetupDrawer({ onClose }: { onClose: () => void }) { + const values = useSetupValues(); + const { data: account } = useAccountProfile(); + const [reveal, setReveal] = useState(false); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
    +
    +
    +
    +
    +

    Your setup

    +

    Commands across these guides fill in with these values.

    +
    + +
    + +
    + DMND token +
    + + {account?.token ? (reveal ? account.token : MASK) : 'Not available'} + + {account?.token && ( + + )} +
    +

    From your account. You never type it.

    +
    + + {Object.values(SETUP_FIELDS).map((field) => ( +
    + + setSetupValue(field.id, e.target.value)} + className="rounded-lg border-[0.5px] border-border bg-card px-3 py-2 text-sm leading-5 text-foreground placeholder:text-placeholder focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + {field.hint &&

    {field.hint}

    } +
    + ))} + +

    + Anything left blank keeps its placeholder in the commands. Passwords and API secrets are never asked for — + replace those yourself before running. Values stay in this browser. +

    +
    +
    + ); +} diff --git a/src/components/docs/DocPrimitives.tsx b/src/components/docs/DocPrimitives.tsx index eb94649..75a2558 100644 --- a/src/components/docs/DocPrimitives.tsx +++ b/src/components/docs/DocPrimitives.tsx @@ -1,17 +1,11 @@ -import { useState, type ReactNode } from 'react'; -import { LiCopy, LiCheckCircle } from 'solar-icon-react/li'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Link } from 'wouter'; +import { LiAltArrowLeft, LiAltArrowRight, LiAltArrowUp, LiAltArrowDown, LiCheckCircle } from 'solar-icon-react/li'; import { BoInfoCircle } from 'solar-icon-react/bo'; import { OlArrowRightUp } from 'solar-icon-react/ol'; import { cn } from '@/lib/utils'; -/** - * Shared building blocks for the long-form guide pages. - * - * The frames draw code in the body face rather than a monospace one. Blocks whose - * meaning depends on column alignment pass `aligned`, which switches them to the - * configured monospace family so their padding survives; everything else keeps the - * drawn face. - */ +/** Shared pieces used by the guide pages: headings, navigation, text, tables and the block drawing. */ /** An inline code chip: the most-used element on these pages. */ export function Chip({ children }: { children: ReactNode }) { @@ -20,11 +14,7 @@ export function Chip({ children }: { children: ReactNode }) { ); } -/** - * The banner that heads each guide. The illustration is exported at 2x from the - * design: it is a dense vector map that would be thousands of inline paths, and the - * two guides crop it differently, so each carries its own asset. - */ +/** The banner image at the top of a guide. */ export function DocHero({ src, height = 153, alt = '' }: { src: string; height?: number; alt?: string }) { return (
    @@ -33,11 +23,7 @@ export function DocHero({ src, height = 153, alt = '' }: { src: string; height?: ); } -/** - * A page section: heading, optional number, and its content. `level` only picks the - * heading element so a numbered subsection (3.1 under 3) nests correctly for screen - * readers; both levels carry identical classes, so the rendering is unchanged. - */ +/** A section with a heading. Use level 3 for a section inside a section. */ export function DocSection({ number, title, @@ -51,7 +37,7 @@ export function DocSection({ }) { const Heading = level === 3 ? 'h3' : 'h2'; return ( -
    +
    {number && {number}} {title} @@ -61,79 +47,233 @@ export function DocSection({ ); } +/** The title at the top of a guide page. Shows "Step 2 of 3" when it is a step. */ +export function DocSectionHeader({ + step, + stepCount, + title, +}: { + step?: number; + stepCount?: number; + title: string; +}) { + return ( +
    + {step !== undefined && ( + + {step} + + )} +
    + {step !== undefined && stepCount && ( +

    + Step {step} of {stepCount} +

    + )} +

    {title}

    +
    +
    + ); +} + +/** One entry in the guide's section nav. */ +interface DocNavItem { + href: string; + label: string; + /** Entries are grouped under this heading, in the order they appear. */ + group: string; + /** A numbered step, drawn as its numeral on the rail instead of a plain dot. */ + step?: number; + /** Ticked off, so the rail shows a check where the numeral was. */ + done?: boolean; + active?: boolean; +} + +/** A guide's list of pages: a side menu on wide screens, a scrolling row of pills on narrow ones. */ +export function DocSectionNav({ items, collapsible = [] }: { items: DocNavItem[]; collapsible?: string[] }) { + const groups: { label: string; items: DocNavItem[] }[] = []; + for (const item of items) { + const last = groups[groups.length - 1]; + if (last?.label === item.group) last.items.push(item); + else groups.push({ label: item.group, items: [item] }); + } + + const activeHref = items.find((i) => i.active)?.href; + const [opened, setOpened] = useState([]); + const isOpen = (label: string) => + !collapsible.includes(label) || + opened.includes(label) || + items.some((i) => i.active && i.group === label); + const toggle = (label: string) => + setOpened((o) => (o.includes(label) ? o.filter((l) => l !== label) : [...o, label])); + + const strip = useRef(null); + useEffect(() => { + // `nearest` vertically: centring the pill must not also scroll the page. + const centre = () => + strip.current?.querySelector('[aria-current]')?.scrollIntoView({ block: 'nearest', inline: 'center' }); + centre(); + // On a cold load the pills are still in the fallback face, narrow enough that the + // strip does not overflow yet and there is nothing to scroll. Once the real face + // lands they are twice as wide, so centre again against the settled widths. + void document.fonts?.ready.then(centre); + }, [activeHref]); + + const pill = 'whitespace-nowrap rounded-full border-[0.5px] px-3 py-1.5 text-sm leading-5 transition-colors'; + + return ( + <> + + + + + ); +} + /** Body copy. */ export function DocText({ children }: { children: ReactNode }) { return

    {children}

    ; } -/** An unordered list. The frames pack these into single text nodes; real list - * semantics are used here so screen readers announce the item count. */ -export function DocList({ items }: { items: ReactNode[] }) { +/** The one-line deck under a section title: what the reader gets, set a size up. */ +export function DocLede({ children }: { children: ReactNode }) { + return

    {children}

    ; +} + +export function DocList({ items, ordered = false }: { items: ReactNode[]; ordered?: boolean }) { + const List = ordered ? 'ol' : 'ul'; return ( -
      + {items.map((item, i) => (
    • {item}
    • ))} -
    + ); } /** - * A copyable code block. `aligned` opts into the monospace family for blocks whose - * columns are space-padded and would otherwise render ragged. + * A note callout: a tinted panel with a coloured left rule. */ -export function CodeBlock({ - code, - aligned = false, - className, +export function DocCallout({ + label, + check = false, + children, }: { - code: string; - aligned?: boolean; - className?: string; + label: string; + check?: boolean; + children: ReactNode; }) { - const [copied, setCopied] = useState(false); - - const copy = () => { - void navigator.clipboard?.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - - return ( -
    -
    -        {code}
    -      
    - - - - -
    - ); -} - -/** A note callout: a tinted panel with a coloured left rule. */ -export function DocCallout({ label, children }: { label: string; children: ReactNode }) { return (
    -

    {label}

    +

    + {check && } + {label} +

    {children}
    ); @@ -141,13 +281,15 @@ export function DocCallout({ label, children }: { label: string; children: React /** A reference table. */ export function DocTable({ headers, rows }: { headers: string[]; rows: ReactNode[][] }) { + const wide = headers.length > 3; + const cell = wide ? 'px-4 py-3' : 'px-6 py-4'; return (
    - +
    {headers.map((h) => ( - ))} @@ -157,7 +299,7 @@ export function DocTable({ headers, rows }: { headers: string[]; rows: ReactNode {rows.map((row, i) => ( {row.map((cell, j) => ( - ))} @@ -169,47 +311,281 @@ export function DocTable({ headers, rows }: { headers: string[]; rows: ReactNode ); } +/** One end of a guide pager: where it goes, and the section it lands on. */ +interface DocPagerLink { + href: string; + label: string; +} + +/** Previous and next buttons at the foot of a guide page. */ +export function DocPager({ prev, next, guide }: { prev?: DocPagerLink; next?: DocPagerLink; guide: string }) { + const button = 'inline-flex shrink-0 items-center gap-1 rounded-lg border-[0.5px] border-border px-3 py-1.5 text-sm leading-5 text-foreground transition-colors group-hover:bg-muted'; + + return ( + + ); +} + /** * The footer prompt that closes every guide page. It is the Info Prompt component in - * its fuchsia variant, the one tint with no token yet, so the two colours are written - * literally the same way the callout above writes its purple. + * its fuchsia variant, the one tint with no token yet. + * + * Its panel is a fixed pale fuchsia in both themes, so its ink is fixed too: the + * theme's `foreground` inverts to near-white in dark mode and would vanish here. */ -export function DocFooterPrompt({ href }: { href: string }) { +export function DocFooterPrompt({ + href, + title = 'Need the complete implementation?', + description = 'Explore the source code, documentation, examples, and setup guides on GitHub.', + linkLabel = 'Open GitHub', +}: { + href: string; + title?: string; + description?: string; + linkLabel?: string; +}) { return ( -
    -
    +
    +
    -

    Need the complete implementation?

    -

    - Explore the source code, documentation, examples, and setup guides on GitHub. -

    +

    {title}

    +

    {description}

    - Open GitHub + {linkLabel}
    ); } -/** The page shell shared by every guide: title, rule, and the content card. */ -export function DocPage({ title, children }: { title: string; children: ReactNode }) { +/** Which part of a block a feature reaches into. */ +export type BlockPart = 'coinbase' | 'transactions' | 'version'; + +const BLOCK_ROWS = [92, 74, 88, 61, 80]; + +export function BlockDrawing({ + highlight, + className, +}: { + /** One part, or several: "the transactions and coinbase" is one claim about two parts. */ + highlight?: BlockPart | BlockPart[]; + className?: string; +}) { + const lit = highlight === undefined ? [] : Array.isArray(highlight) ? highlight : [highlight]; + const on = (part: BlockPart) => lit.includes(part); + const region = (part: BlockPart) => + cn('rounded p-1 transition-opacity', on(part) && 'bg-info/10', lit.length > 0 && !on(part) && 'opacity-30'); + return ( -
    -
    -

    {title}

    -
    -
    -
    {children}
    +
    +
    + +
    +
    +
    + {BLOCK_ROWS.map((w, i) => ( + + ))} +
    +
    +
    + + {Array.from({ length: 9 }, (_, i) => ( + + ))} + +
    +
    + ); +} + +/** One thing owning your block gets you. `parts` ties it to the drawing. */ +interface BlockPoint { + text: string; + parts?: BlockPart | BlockPart[]; +} + +export function BlockPoints({ points }: { points: BlockPoint[] }) { + const [lit, setLit] = useState(); + + return ( +
    + +
    + {points.map((point, i) => ( +
    setLit(point.parts)} + onMouseLeave={() => setLit(undefined)} + className={cn( + 'flex gap-3 rounded-lg px-2 py-2.5 transition-colors', + i > 0 && 'border-t-[0.5px] border-border', + point.parts && lit === point.parts && 'bg-muted', + )} + > + + {point.text} +
    + ))} +
    +
    + ); +} + +export function DocMarkedList({ items }: { items: ReactNode[] }) { + return ( +
      + {items.map((item, i) => ( +
    • + + {item} +
    • + ))} +
    + ); +} + +/** One labelled box in the stack diagram. */ +function StackBox({ name, note }: { name: string; note?: string }) { + return ( +
    +

    {name}

    + {note &&

    {note}

    } +
    + ); +} + +/** A labelled arrow. `both` draws a head at each end, for a two-way link. */ +function StackArrow({ + label, + port, + both = false, + vertical = false, +}: { + label: string; + port?: string; + both?: boolean; + vertical?: boolean; +}) { + const head = 'h-0 w-0 shrink-0 border-transparent'; + if (vertical) { + return ( + + + + + + {label} + + ); + } + return ( + + {label} + + {both && } + + + + {port && {port}} + + ); +} + +export function StackDiagram() { + return ( +
    + {/* Wide: the real topology, on a five-column grid so the branch sits under the client. */} +
    + + + + + + + + + + + + + + + + + + + +
    + + {/* Narrow: the same links, read top to bottom. */} +
    + + + + + + + templates into the client + + + + + +
    ); } diff --git a/src/pages/build-your-block/BuildYourBlockPage.tsx b/src/pages/build-your-block/BuildYourBlockPage.tsx new file mode 100644 index 0000000..8ee8618 --- /dev/null +++ b/src/pages/build-your-block/BuildYourBlockPage.tsx @@ -0,0 +1,114 @@ +import type { ReactNode } from 'react'; +import { Link } from 'wouter'; +import { LiAltArrowRight } from 'solar-icon-react/li'; +import heroImage from '@/assets/guide-hero-job-declaration.png'; +import { + BlockDrawing, + DocHero, + DocLede, + DocPager, + DocSection, + DocText, + type BlockPart, +} from '@/components/docs/DocPrimitives'; +import { + DocPage, +} from '@/components/docs/DocGuide'; +import { cn } from '@/lib/utils'; + +const JOB_DECLARATION_PATH = '/build-your-block/job-declaration'; +const MERGE_MINING_PATH = '/build-your-block/merge-mining'; +const PRIORITIZE_TRANSACTIONS_PATH = '/build-your-block/prioritize-transactions'; + +/** One feature: what it is called, what it does, and the way into it. */ +function Feature({ + title, + href, + action, + part, + main = false, + children, +}: { + title: string; + href: string; + action: string; + /** The part of the block this one reaches into; omitted means the whole block. */ + part?: BlockPart; + main?: boolean; + children: ReactNode; +}) { + return ( +
    + +
    +

    + {title} +

    +
    {children}
    + + {action} + + +
    +
    + ); +} + +/** The introduction: what building your own block is, and the features that do it. */ +export function BuildYourBlockPage() { + return ( + + + + + Build your own block templates from your own node, and the pool pays you for the valid blocks you find. + + + + + + Keep the convenience of pooled mining while your local Bitcoin node builds the template. DMND accounts for + shares and payouts without deciding your block contents. + + + No ASIC replacement or firmware reflash is needed: miners still speak standard Stratum V1 to your local DMND + Client. + + + + + + + + Add a Rootstock commitment to the coinbase you control. Use the same ASICs and Bitcoin hash rate, with rBTC + directed to an address you choose. + + + + + Favor an eligible transaction in your local template for fee recovery, private inclusion, or a paid + inclusion workflow. + + + + + + + ); +} diff --git a/src/pages/build-your-block/JobDeclarationPage.tsx b/src/pages/build-your-block/JobDeclarationPage.tsx index 73d085e..5cca946 100644 --- a/src/pages/build-your-block/JobDeclarationPage.tsx +++ b/src/pages/build-your-block/JobDeclarationPage.tsx @@ -1,55 +1,168 @@ -import { LiArrowRightUp } from 'solar-icon-react/li'; +import { useEffect, useRef, type ReactNode } from 'react'; +import { Link, useRoute } from 'wouter'; +import { LiArrowRightUp, LiAltArrowRight } from 'solar-icon-react/li'; import heroImage from '@/assets/guide-hero-job-declaration.png'; import { + BlockDrawing, + BlockPoints, + Chip, + DocCallout, + DocFooterPrompt, DocHero, - DocPage, + DocLede, + DocList, + DocPager, DocSection, + DocSectionHeader, + DocSectionNav, DocText, - DocList, - Chip, - DocFooterPrompt, + StackDiagram, } from '@/components/docs/DocPrimitives'; +import { + CodeBlock, + CommandLegend, + DocPage, + StepDone, + useGuideTicks, +} from '@/components/docs/DocGuide'; const SV2_TP_README = 'https://github.com/stratum-mining/sv2-tp#readme'; -const DMND_CLIENT_REPO = 'https://github.com/dmnd-pool/dmnd-client'; +const DMND_CLIENT_SETUP = 'https://github.com/dmnd-pool/dmnd-client#4-run-the-dmnd-client'; +const DMND_JD_BLOCK = 'https://blog.dmnd.work/dmnd-mines-the-first-known-bitcoin-block-using-stratum-v2-job-declaration/'; +const DMND_SIGNALING = 'https://blog.dmnd.work/miner-signaling-via-dmnd-stratum-v2-you-decide-what-to-signal/'; +const DIRECT_CONNECTION_PATH = '/workers'; +const MERGE_MINING_PATH = '/build-your-block/merge-mining'; +const PRIORITIZE_TRANSACTIONS_PATH = '/build-your-block/prioritize-transactions'; -/** - * Enable job declaration support: what a miner runs to build their own block - * templates. - * - * The Bitcoin Core version differs from the design, which says v30+. The Template - * Provider's own release notes require v31.0 or later, and its last release - * supporting v30.2 predates the current IPC changes, so following the drawn version - * would leave a miner unable to connect. - */ -export function JobDeclarationPage() { - return ( - - - - - Job Declaration is the key Stratum V2 feature that lets miners build their own block templates, improving - decentralization, censorship resistance, and latency. To use it, you run two components on your own - infrastructure: - - - - Bitcoin Core (v31.0+) with IPC enabled — your own - node. - , - <> - Stratum V2 Template Provider (sv2-tp) - — a separate binary that connects to Bitcoin Core via IPC and serves block templates to the DMND Client. - , - ]} - /> +const GUIDE_NAME = 'Job Declaration'; +const BUILD_YOUR_BLOCK = 'BUILD YOUR BLOCK'; +const BUILD_YOUR_BLOCK_PAGE = '/build-your-block'; +const BASE_PATH = '/build-your-block/job-declaration'; + +const MINER_CONNECTION = 'stratum+tcp://:32767'; + +interface GuideSection { + slug: string; + title: string; + navTitle?: string; + group: string; + step?: number; + content: () => ReactNode; +} + +const SECTIONS: GuideSection[] = [ + { + slug: 'why', + title: 'Build your own blocks with Job Declaration', + navTitle: 'Why build your own block', + group: 'Overview', + content: () => ( + <> + + Mine with a pool without giving the pool control of your block. Your Bitcoin node builds the template; DMND + validates declared job, accounts for shares, and pays you for pooled mining. + + + Job Declaration is production infrastructure, not a demo. DMND mined the first known Bitcoin block produced with Stratum V2 Job Declaration on mainnet. It also gives miners control over block + signaling and DMND does not edit a miner's declared transaction selection or nVersion bits. + + + + + Miner signaling via DMND Stratum V2: You decide what to signal + + + + - + + + + Your ASICs do not need Stratum V2 firmware. They connect to the DMND Client with standard Stratum V1, while + the client handles the SV2 and Job Declaration connection upstream. + + + + + Want the simplest path to mining with DMND? Use the managed pool endpoint and credentials on{' '} + + Workers + + . Choose this setup when you want direct control over the block your miners work on. + + + + + {/* The diagram carries names and ports; these say what each part is for. Ordered + the way a template actually travels, which is not the order the drawing reads. */} +
    + {[ + { + name: 'Bitcoin Core', + text: 'Your synchronized node holds the mempool and originates the templates.', + }, + { + name: 'sv2-tp', + text: 'The Template Provider connects to Core through IPC and serves templates locally.', + }, + { + name: 'DMND Client', + text: 'Your miners connect here. It receives templates and declares your custom work to DMND.', + }, + ].map((part) => ( +
    +
    {part.name}
    +
    — {part.text}
    +
    + ))} +
    +
    + + ), + }, + { + slug: 'node-and-template-provider', + title: 'Run Bitcoin Core and the Template Provider', + navTitle: 'Node and sv2-tp', + group: 'Set it up', + step: 1, + content: () => ( + <> - Follow the setup instructions in the sv2-tp README — it covers both running Bitcoin Core with IPC enabled and - running the Template Provider, and is always up to date: + Bitcoin Core holds your mempool and originates the templates. The Template Provider (sv2-tp) + connects to it through IPC and serves those templates to the DMND Client. + + + + The sv2-tp README covers both halves of this step — running Bitcoin Core with IPC enabled, and + running the Template Provider — and is kept up to date with each release: - {SV2_TP_README} + Set up Bitcoin Core and sv2-tp + + + The Template Provider listens on port 8336 by default, which is the address you give the DMND + Client in the next step. + + + + Current sv2-tp releases require Bitcoin Core v31.0+, because of breaking changes in + the IPC mining interface. If you intentionally run Bitcoin Core v30.2, use the legacy{' '} + sv2-tp v1.0.6 release instead. + + + + ), + }, + { + slug: 'dmnd-client', + title: 'Run DMND Client and connect your miners', + navTitle: 'DMND Client', + group: 'Set it up', + step: 2, + content: () => ( + <> - The Template Provider listens on port 8336 by default — you'll need that in the next section. + Run the DMND Client with your DMND token and the Template Provider address. It connects upstream to DMND, + receives your local templates, and exposes a standard Stratum V1 endpoint for your ASICs. + + Continue with DMND Client setup + + + Once the client is healthy, point each ASIC at the machine running it: + + + + + ), + }, + { + slug: 'verify', + title: 'Verify it is working', + navTitle: 'Verify', + group: 'Set it up', + step: 3, + content: () => ( + <> + + Bitcoin Core is fully synchronized, and the sv2-tp log shows a successful IPC connection and new + templates as blocks arrive. + + + + The DMND Client log shows connections to both sv2-tp and DMND, with templates being declared. + Your miners connect normally and begin submitting shares. + + + ), + }, + { + slug: 'build-on-it', + title: 'Build on your Job Declaration setup', + navTitle: 'Build on it', + group: 'Next', + content: () => ( + <> - Verify: the sv2-tp log should show a successful IPC - connection to Bitcoin Core and new templates being generated as blocks arrive. + Once templates are declaring successfully, you can extend the block you control in two optional ways: -
    +
    + + + + + Earn direct rBTC with merge mining + + + Merge mining + + + + + + + + + Prioritize transactions in your templates + + + Prioritize transactions + + + + +
    + + ), + }, +]; + +const STEP_COUNT = SECTIONS.filter((s) => s.step).length; +const sectionPath = (slug: string) => `${BASE_PATH}/${slug}`; +const sectionLabel = (s: GuideSection) => { + const name = s.navTitle ?? s.title; + return s.step ? `Step ${s.step}: ${name}` : name; +}; + +/** + * Enable job declaration support: what a miner runs to build their own block templates. + * + * The Bitcoin Core version differs from the design, which says v30+. The Template + * Provider's own release notes require v31.0 or later, and its last release supporting + * v30.2 predates the current IPC changes, so following the drawn version would leave a + * miner unable to connect. + */ +export function JobDeclarationPage() { + const [, params] = useRoute(`${BASE_PATH}/:section`); + const found = SECTIONS.findIndex((s) => s.slug === params?.section); + const index = found === -1 ? 0 : found; + const section = SECTIONS[index]; + const prev = SECTIONS[index - 1]; + const next = SECTIONS[index + 1]; + + // Setting this up spans days, so a finished step stays finished across visits. + const { ticked, toggle } = useGuideTicks('job-declaration-steps'); + + const root = useRef(null); + useEffect(() => { + root.current?.closest('main')?.scrollTo({ top: 0 }); + }, [index]); + + return ( +
    + + {index === 0 && } + +
    + ({ + href: sectionPath(s.slug), + label: s.navTitle ?? s.title, + group: s.group, + step: s.step, + done: s.step !== undefined && ticked.includes(s.slug), + active: s.slug === section.slug, + }))} + /> + +
    + + + {section.content()} + + {section.step !== undefined && ( + toggle(section.slug)} /> + )} + + - - + +
    +
    +
    +
    ); } diff --git a/src/pages/build-your-block/MergeMiningPage.tsx b/src/pages/build-your-block/MergeMiningPage.tsx index b4c4a13..90d76ff 100644 --- a/src/pages/build-your-block/MergeMiningPage.tsx +++ b/src/pages/build-your-block/MergeMiningPage.tsx @@ -1,26 +1,60 @@ +import { useEffect, useRef, type ReactNode } from 'react'; +import { Link, useRoute } from 'wouter'; +import { LiArrowRightUp, LiAltArrowDown } from 'solar-icon-react/li'; +import { BoInfoCircle } from 'solar-icon-react/bo'; import heroImage from '@/assets/guide-hero-merge-mining.png'; import { - DocPage, DocHero, DocSection, + DocSectionHeader, + DocSectionNav, + DocLede, DocText, DocList, DocTable, - CodeBlock, + DocCallout, + DocPager, Chip, DocFooterPrompt, } from '@/components/docs/DocPrimitives'; +import { + DocPage, + StackSummary, + GuidePrereqs, + StepDone, + useGuideTicks, + CodeBlock, +} from '@/components/docs/DocGuide'; -const DMND_CLIENT_REPO = 'https://github.com/dmnd-pool/dmnd-client'; +const DMND_CLIENT_MERGE_MINING = 'https://github.com/dmnd-pool/dmnd-client/blob/master/MERGE_MINING.md'; +const RUST_INSTALL = 'https://www.rust-lang.org/tools/install'; +const JOB_DECLARATION_PAGE = '/build-your-block/job-declaration'; +const BUILD_YOUR_BLOCK_PAGE = '/build-your-block'; -const RSK_PAYLOAD_LAYOUT = `ASCII "RSKBLOCK:" 52534b424c4f434b3a -blockHashForMergedMining 32 bytes / 64 hexadecimal characters -complete payload 41 bytes / 82 hexadecimal characters`; +const GUIDE_NAME = 'Merge mining'; +const BUILD_YOUR_BLOCK = 'BUILD YOUR BLOCK'; +const BASE_PATH = '/build-your-block/merge-mining'; -const DECLARATION_FLOW = `one NewTemplate -> one miner-facing extended job -> one DeclareMiningJob - -> one SetCustomMiningJob -> one pool job mapping`; +const CLIENT_RUN = `API_BIND_ADDRESS=127.0.0.1 \\ +API_SECRET= \\ +TOKEN= \\ +./dmnd-client -l info -d 250T --tp-address="127.0.0.1:8336"`; -const CANONICAL_SCRIPT = 'OP_RETURN '; +const RSK_REWARD_ADDRESS = 'reward.address = ""'; + +const RSKJ_RUN = `docker run --rm \\ + -e RSKJ_SYS_PROPS='-Drpc.providers.web.http.bind_address=127.0.0.1 \\ + -Drpc.modules.mnr.version=1.0 -Drpc.modules.mnr.enabled=true \\ + -Dminer.server.enabled=true -Dminer.reward.address=' \\ + rsksmart/rskj:VETIVER-9.0.3`; + +const BRIDGE_BUILD = 'cargo build --release -p demand-rsk-op-return-bridge'; + +const BRIDGE_RUN = `RSK_RPC_URL=http://127.0.0.1:4444 \\ +DMND_CLIENT_API_SECRET= \\ +DMND_CLIENT_OP_RETURN_URL=http://127.0.0.1:3001/api/coinbase/op-return \\ +DMND_CLIENT_FOUND_JOB_URL=http://127.0.0.1:3001/api/merge-mining/found-job \\ +./target/release/demand-rsk-op-return-bridge`; const SUCCESS_ENVELOPE = `{ "success": true, @@ -115,326 +149,507 @@ const RAW_BLOCK = 'raw_block_hex = block_header_hex || "01" || coinbase_tx_hex'; const SUBMIT_BLOCK = 'mnr_submitBitcoinBlock(raw_block_hex)'; -const RSKJ_FLAGS = '-Drpc.modules.mnr.enabled=true -Dminer.server.enabled=true'; - -const PROXY_INVOCATION = `API_BIND_ADDRESS='127.0.0.1' \\ -API_SECRET='' \\ -TOKEN='' \\ -cargo run -- -l info -d 'T' --tp-address='127.0.0.1:8336'`; - -const COMPANION_PATH = '../demand/rust-backend/services/demand-rsk-op-return-bridge/'; - -/** - * Merge mining in dmnd: how RSK merge mining works in the proxy, and the wire - * contract a bridge must satisfy. - * - * The samples here are operational: a bridge author copies them verbatim, so each - * one is transcribed from the client's own MERGE_MINING.md rather than retyped. - */ -export function MergeMiningPage() { - return ( - - - - - This document explains how RSK merge mining is implemented in this proxy and defines the wire contract for a - bridge that connects the proxy to RskJ. - - - The primary safety invariant is: - - - Merge mining is optional. No merge-mining failure may invalidate Bitcoin work, suppress an otherwise valid - Bitcoin share or block solution, disconnect miners, or bring down the proxy. - +/** The RFC-style keywords the wire contract is written in. */ +function Must({ children }: { children: ReactNode }) { + return {children}; +} - - The words MUST, MUST NOT, SHOULD, and MAY describe requirements for a compatible bridge. Details explicitly - described as current limits are implementation details of this proxy. - +interface GuideSection { + /** The URL segment under `/build-your-block/merge-mining`. */ + slug: string; + title: string; + /** Short form for the nav and pager, where a full headline will not fit. */ + navTitle?: string; + /** The heading it is filed under in the section nav, in this order. */ + group: string; + /** Set on the setup steps; it numbers them and nothing else. */ + step?: number; + content: () => ReactNode; +} - - The integration has two independent directions: - - The proxy and bridge must run as separately supervised processes. The bridge may fail or restart without - restarting dmnd-client; dmnd-client may lose RSK opportunities without interrupting - Bitcoin mining. - - - - All of the following are required: - - dmnd-client runs in Job Declaration mode with --tp-address and a reachable - Template Provider. - , - <> - API_SECRET is non-empty and shared with the bridge. - , - 'The Template Provider honors the additional coinbase-output capacity advertised by the proxy.', - 'The pool accepts every Bitcoin-consensus-valid template that also satisfies its ordinary token/tip policy.', - 'RskJ enables its merge-mining and miner RPC modules.', - ]} - /> +const TROUBLE: { step: number; issue: ReactNode; check: ReactNode }[] = [ + { + step: 1, + issue: ( + <> + 503 Service Unavailable from the client + + ), + check: ( + <> + Confirm a non-empty API_SECRET is set on the client. + + ), + }, + { + step: 1, + issue: 'Connection refused on port 3001', + check: 'Check the client’s API port, bind address, and local firewall. Do not fix this by exposing the port.', + }, + { + step: 1, + issue: ( + <> + RskJ reports method not found + + ), + check: ( + <> + Enable the mnr module and the miner server, then restart RskJ. + + ), + }, + { + step: 2, + issue: ( + <> + 401 Unauthorized from the client + + ), + check: ( + <> + DMND_CLIENT_API_SECRET must exactly match the client’s API_SECRET. + + ), + }, + { + step: 2, + issue: 'Jobs expire immediately', + check: ( + <> + Check UTC clock sync between hosts and FOUND_JOB_MAX_AGE_SECS. + + ), + }, + { step: 2, issue: 'Repeated rate-limit messages', check: 'Let the bridge cooldown finish, then review the RskJ rate-limit policy.' }, + { + step: 2, + issue: 'Repeated transport timeouts', + check: 'Check the route, service health, and proxy bypass. The client uses 5s connect / 20s request timeouts.', + }, + { + step: 4, + issue: 'Work is fetched but no RSK jobs reach miners', + check: 'Confirm RskJ is synced, the client is in Job Declaration mode, and its Template Provider is serving new templates.', + }, +]; - - Merge mining does not negotiate a private SV2 capability and does not require any pool or Job Declaration - protocol change. Setup requests and responses use the ordinary upstream protocol; flags = 0 is - valid. - +/** The issues for one step, closed until something has gone wrong. */ +function StepTrouble({ step }: { step: number }) { + const rows = TROUBLE.filter((t) => t.step === step); + if (!rows.length) return null; + return ( +
    + + + + Troubleshooting + + + +
    + [t.issue, t.check])} /> +
    +
    + ); +} - - An HTTP 202 Accepted response does not prove that an RSK job was sent to miners. It means only - that the pair is stored and available for a subsequent compatible NewTemplate. - +const MERGE_MINING_EXTRA = [ + { + id: 'rust', + name: ( + <> + A Rust 2024 toolchain to build the bridge.{' '} + + Install Rust + + + + ), + label: 'A Rust 2024 toolchain to build the bridge.', + }, + { + id: 'supervisor', + name: 'A process supervisor (systemd, Docker Compose) that restarts the bridge on its own, independently of the client and the pool.', + }, + { + id: 'rskj', + label: 'RskJ node', + coveredNext: true, + name: ( + <> + A synchronized RskJ node on mainnet, release VETIVER-9.0.3, with the mnr RPC module + and the miner server enabled and the node fully synced. There is no testnet pool endpoint, so RSK merge mining + runs on mainnet. + + ), + }, + { + id: 'client-build', + label: 'DMND Client build with merge-mining support', + coveredNext: true, + name: ( + <> + A DMND Client build with merge-mining support. The published v0.3.28 release predates the feature + and will not work, so until a release ships with it merged, build and pin the reviewed commit{' '} + b1d46b306f1415770e2dba9232aa47d6ca335999 (package 0.3.29) or a later reviewed + revision that keeps the same contract. + + ), + }, + { + id: 'clocks', + coveredNext: true, + name: 'Both hosts synchronized to UTC, because the bridge expires jobs using the client’s timestamps and clock drift makes jobs look stale.', + }, +]; - - The proxy advertises 100 additional serialized coinbase-output bytes to the Template Provider. The standard - RSK commitment consumes 52 bytes. - -
    +/** + * The guide, one section per page. + * + * The operator path comes first: what the miner gets, readiness, setup, security, + * verification, and support. + * + * The last three are the wire contract from the client's MERGE_MINING.md. They are for + * anyone writing their own bridge rather than running the companion one, so they are placed + * under Advanced, collapsed, and not part of the main operator path. + */ +const SECTIONS: GuideSection[] = [ + { + slug: 'earn-rbtc', + title: 'Add direct rBTC rewards to the blocks you already build', + navTitle: 'Earn rBTC', + group: 'Overview', + content: () => ( + <> + + Your Job Declaration setup already puts the coinbase in your hands. Add a Rootstock commitment and earn rBTC + to an address you control using the same ASICs and Bitcoin hash rate—without changing your Bitcoin mining + path. + - - - The bridge obtains work from RskJ and sends one atomic pair to the proxy: - - the RSKBLOCK: OP_RETURN payload; and - , - 'the RSK target belonging to that exact payload.', - ]} - /> - - The most recently accepted pair remains active for later templates until another pair replaces it. - Replacement never rewrites an older template: every template generation keeps the payload and target that - were current when that generation was prepared. - - - POSTing a pair does not request or synthesize a fresh Template Distribution template. The pair becomes - eligible when a subsequent compatible NewTemplate is processed. Jobs already published for an - older pair can remain valid and can produce found-job responses after a newer pair is installed. - - - The desired pair survives Mining, Job Declaration, and Template Provider reconnects within the same{' '} - dmnd-client process. It is not persisted across a complete process restart. - - + + Merge mining adds an RskJ node and bridge service, but requires no new ASIC hardware or firmware. + - - - For each compatible Template Distribution NewTemplate, the proxy first keeps a pristine copy. - It validates the complete MM change and then applies it to the one canonical template used by both the - miner-facing job factory and Job Declaration. The original Template Distribution template ID is never - replaced with a synthetic ID. - - The canonical template receives a zero-value output whose script is exactly: - - For RSK, the payload is exactly 41 bytes: - - - The RskJ work hash is appended in the orientation returned by mnr_getWork; it is not reversed. - The output leaves all existing outputs and coinbase_tx_value_remaining unchanged and increments - the output count once. - - - RskJ locates work by scanning the complete witness-stripped coinbase bytes without respecting - transaction-field, output, or script boundaries. The last raw occurrence of RSKBLOCK: must - begin the exact desired 41-byte payload, and no more than 128 bytes may follow the 32-byte work hash. The - 128-byte limit is inclusive and includes later output bytes and locktime. A newly appended final canonical - output normally has only the four-byte locktime after its hash. - - The proxy still requires its own commitment to be a canonical OP_RETURN output: - - if a different or hidden raw RSKBLOCK: follows it, or more than 128 bytes follow its hash, - the desired canonical commitment is appended again; - , - 'unrelated outputs remain byte-for-byte unchanged; and', - <> - an RSK work hash containing another raw RSKBLOCK: marker is rejected before it becomes - active, because RskJ could not select the leading intended commitment unambiguously. - , - ]} + + + On top of that stack you need: + - - Before publication, the proxy applies the raw scan again to the prospective serialized outputs plus - locktime. This also rejects a later marker assembled across serialized field boundaries, such as a - work-hash suffix combined with the locktime bytes. - - - Injection is rejected if the output does not fit the reserved bytes, the existing outputs cannot be decoded, - an SV2 field would overflow, MM state is unavailable, or the current coinbase converter could cross its safe - one-byte output-count range. The proxy decodes and counts every output in the pool token and permits - injection only when {'template outputs + pool outputs <= 252'}. With the current one-output - pool token, the canonical template may contain at most 251 outputs. A token with multiple outputs lowers - that limit accordingly, and the same full output set is used by the miner job and Job Declaration. The exact - canonical desired RSK output is accepted idempotently when it satisfies the raw-selection rules and the - final combined count is safe. If another output cannot be appended safely, the byte-for-byte pristine - template is used for the one normal Bitcoin job, as it is on every other pre-publication MM failure. - - - The modified candidate is also passed through a throwaway instance of the pinned coinbase job builder with - every pool output and the live channel’s extranonce length. This verifies the complete miner-facing - coinbase prefix and suffix, not only the Template Distribution output field. The throwaway builder cannot - mutate the live channel factory. If either complete field cannot fit its B064K representation, - the unpublished MM generation is discarded and the byte-for-byte pristine template is published once through - the ordinary flow. - - - - Every processed template produces only the existing normal sequence: - - - There is no optional token, second declaration, second custom job, capability gate, delayed upgrade, or - replacement notify. The declaration uses the coinbase prefix and suffix from that exact miner-facing job. - Custom-job responses are correlated by request ID, then map the exact local miner job ID to its accepted - pool job ID; template IDs are not used as a latest-job shortcut. - + + ), + }, + { + slug: 'configuration', + title: 'Prepare the DMND Client and RskJ', + group: 'Set it up', + step: 1, + content: () => ( + <> + - On SetNewPrevHash, the proxy records immutable MM chain context first and then preserves the - existing orchestration order: start the Job Declarator transition before publishing the matching prevhash to - miners. It does not wait for the pool response before publication. + The bridge talks to the client over a small protected API, so bind that API to loopback and give it a + strong, dedicated secret. The client’s default API bind is not loopback, so set it explicitly: + - The existing proxy publishes miner work before the ordinary declaration/custom-job exchange has completed. - This design therefore relies on the deployment requirement above: the pool accepts any - Bitcoin-consensus-valid template under its normal token/tip rules. The injected zero-value canonical - OP_RETURN is locally validated before publication and fits the pool’s advertised 100-byte allowance. A - later token, tip, transport, or generic JD rejection is an ordinary Job Declaration failure that can affect - a clean job in the same way; it is not handled by a second MM attempt. + This is the same client run line from the setup guide with the merge-mining API switched on. The API + defaults to port 3001 (--api-server-port, short form -s). The secret you set here + has to match the one you give the bridge in the next step. + + The client is running in Job Declaration mode, declaring templates to the pool, with the API listening on{' '} + 127.0.0.1:3001. + - - Every published RSK job is bound to one immutable template generation containing: - NewTemplate.template_id], - ['Payload and target', 'Atomic pair applied to that generation'], - ['Merkle siblings', NewTemplate.merkle_path], - ['Transaction count', RequestTransactionDataSuccess.transaction_list.length + 1], - [ - <> - Previous block hash and nBits - , - <> - Matching SetNewPrevHash - , - ], - ['Coinbase prefix and suffix', 'Accepted live miner job'], - ['Miner job binding', 'Exact miner-facing extended job'], - ]} - /> - - The transaction count includes the coinbase and is never inferred from merkle-path length. Non-future - templates inherit the active chain state, which covers the normal{' '} - SetNewPrevHash(A) -> NewTemplate(B, future=false) refresh. Future templates remain - incomplete until their matching SetNewPrevHash arrives. A job binding immediately retains its - immutable template context; no pending-upgrade pin or second publication phase exists. - + - Reused template or job IDs are separated by local generations. The transaction-data request keeps the - generation selected when the request was made; its response writes the transaction count once to that - generation rather than looking up a reusable template ID later. A share never falls back to the newest - template or to context belonging to another job. Miner-job announcements are matched by job ID and stale - earlier announcements are discarded deliberately, so one missing job cannot shift every later merge-mining - binding. + The setting that matters most is where you get paid. rBTC rewards go to an RSK address configured on your + node, so for a live mainnet deployment, set an explicit address you control: + - The context behind the last miner-facing notify, the selected future job awaiting its prevhash, and - bindings already queued for ordered notify delivery are protected from bounded-history eviction. Claiming a - binding and applying that protection is atomic; when future-job coalescing replaces a future, the discarded - binding is released. Once a newer notify becomes active, older inactive contexts are eligible for normal - retirement. If every bounded slot is temporarily protected, the incoming template remains pristine and - Bitcoin-only instead of evicting context that a miner can use. + The alternative, miner.coinbase.secret, is a passphrase RskJ uses to derive an address into a + local wallet; RSK does not recommend it for production, so treat it as testing only. This setting is + separate from the client API secret and does not need to match it. Rewards arrive through RSK’s Reward + Manager (REMASC) after a maturity delay, not the instant a block is found, so an empty balance right after + your first block is not a failure. Think of RSK as a bonus on the same work, not a second full income + stream. - - - - After authentication and structural validation, the proxy offers each submitted share to a bounded RSK - observer before normal Bitcoin-difficulty filtering. The offer uses a nonblocking queue. A full or - unavailable observer loses only that RSK observation. + Beyond the payout address, RskJ has to expose HTTP RPC with the mnr module and the miner server + on. The example below binds the RPC listener to loopback and runs on mainnet. Treat it as a starting point: + for a real deployment, follow the official RskJ node docs and manage node configuration through a protected + service setup rather than ad hoc command lines. - For an observed share, the worker: + + Three settings carry the work here: - reconstructs and deserializes coinbase_prefix || full_extranonce || coinbase_suffix; + rpc.modules.mnr.enabled=true exposes mnr_getWork,{' '} + mnr_submitBitcoinBlock, and mnr_submitBitcoinBlockPartialMerkle. , <> - verifies that the expected payload is the last canonical RSKBLOCK: commitment; + miner.server.enabled=true is required for mnr_getWork. , <> - clears all coinbase input witness stacks, serializes the coinbase once, and verifies that the expected - payload starts at the last raw RSKBLOCK: occurrence with at most 128 trailing bytes; - , - 'computes the witness-stripped coinbase txid;', - "reconstructs the merkle root from the template's bottom-up sibling path;", - <> - builds the 80-byte Bitcoin header from the share version, timestamp and nonce plus the exact prevhash, - merkle root and nBits; - , - 'compares the header hash numerically with the template-scoped RSK target; and', - <> - enqueues the proof only when {'bitcoin_block_hash <= rsk_target'}. + RSK_RPC_URL, set on the bridge in the next step, must point at this listener, normally{' '} + http://127.0.0.1:4444. , ]} /> - This side path never changes the result of normal Bitcoin share validation. A share or solution continues - through its configured Bitcoin relay and block-submission paths even if every RSK step fails. + Leave the bind address on loopback rather than 0.0.0.0. + + The node is synced and mnr_getWork responds over the RPC listener. + - - - - - An unavailable observer makes the merge-mining API unavailable. If it is unavailable while a new template is - being prepared, the proxy uses the pristine Bitcoin template. If it fails after a job was bound, later - observations may be lost, but the job, Bitcoin shares, and Bitcoin block solution path are unchanged. A - thread-spawn failure is retried after a five-second backoff; an unexpected worker exit is retried on the - next operation that needs it. API bind failures likewise leave mining active and retry every five seconds; - an API serve failure retries after one second. - - - - - + + ), + }, + { + slug: 'run-the-bridge', + title: 'Build and run the bridge', + group: 'Set it up', + step: 2, + content: () => ( + <> + + Build the bridge from this repo + + + Then run it with its environment configured through your supervisor: + + + Only two variables are required: RSK_RPC_URL and DMND_CLIENT_API_SECRET (which must + exactly match the client’s API_SECRET). Everything else has a sane default: + + RSK_RPC_URL, 'Yes', 'none', 'Protected RskJ HTTP RPC listener.'], + [ + DMND_CLIENT_API_SECRET, + 'Yes', + 'none', + <> + Must exactly match the client API_SECRET. + , + ], + [ + DMND_CLIENT_OP_RETURN_URL, + 'No', + http://127.0.0.1:3001/api/coinbase/op-return, + 'Where the RSK commitment is posted.', + ], + [ + DMND_CLIENT_FOUND_JOB_URL, + 'No', + 'Derived from the OP_RETURN URL', + 'Where found jobs are polled. Set explicitly if the OP_RETURN URL is custom.', + ], + [RSK_POLL_INTERVAL_SECS, 'No', 1, 'How often to poll RskJ for work.'], + [ + FOUND_JOB_POLL_INTERVAL_SECS, + 'No', + 1, + 'How often to poll the client for found jobs.', + ], + [JOB_RETRY_INTERVAL_SECS, 'No', 5, 'Delay between submission retries.'], + [FOUND_JOB_MAX_AGE_SECS, 'No', 600, 'Oldest job age still worth submitting.'], + [ + DMND_CLIENT_WORK_RESYNC_INTERVAL_SECS, + 'No', + 60, + 'Idempotent repost of unchanged work.', + ], + [MAX_PENDING_FOUND_JOBS, 'No', 256, 'Proofs held in bridge memory.'], + ]} + /> + + The binary loads a local .env file if one exists. A configuration error exits with status 2; + runtime RPC and HTTP errors are logged and retried, because temporary RskJ failures are expected. + + + Run the bridge and the client as separate supervised processes with independent restart policies. A bridge + crash must not restart the client. When the client restarts, restart the bridge once the client API is healthy + so it reposts the current RSK work; reconnects inside a still-running client process keep the desired pair on + their own. Keep both hosts synchronized to UTC with NTP or chrony, since proof expiry uses the client’s + timestamp. + + + ), + }, + { + slug: 'security', + title: 'Secure the control plane', + group: 'Set it up', + step: 3, + content: () => ( + <> + The bridge model only holds if the control plane stays private. + + Neither the client API nor the RskJ RPC listener may face the public internet. Run RskJ, the bridge, and + the client on one host with both listeners bound to 127.0.0.1; across hosts, carry the + traffic over an authenticated private tunnel (TLS/mTLS or an encrypted network). A private IP by itself is + not authentication. + , + <> + API_SECRET is application authentication, not a network perimeter. It proves the caller; it + does not firewall the port. + , + 'The found-job request carries the secret in its URL query string. Any proxy, access log, or APM in front of these services must redact full request targets and query strings. Never put the secret, tokens, or credentials in an endpoint URL.', + 'Keep secrets distinct. The client API secret, the RskJ miner secret, and your mining token are three different things. Do not reuse one for another.', + ]} + /> + + ), + }, + { + slug: 'verify-it-is-working', + title: 'Verify it is working', + group: 'Set it up', + step: 4, + content: () => ( + <> + + The bridge exposes no health endpoint, so you monitor it through its process and its logs. Healthy operation + shows three log lines: + + + queued RSK merge-mining payload into dmnd-client — new or resynced RSK work was accepted by + the client. + , + <> + queued found merge-mining job for RSK submission — a miner found a qualifying share + (naturally rare). + , + <> + submitted merge-mined Bitcoin block to RSK — RskJ accepted a submission. + , + ]} + /> + + The submission log tags a submission_mode: partial-merkle for a multi-transaction + block, coinbase-only-block for a one-transaction block. Found-job and submission logs are sparse + by nature, so their absence alone does not mean the bridge is broken, only that no RSK-qualified share has + come up yet. + + + Your logs may contain job IDs, template IDs, and work hashes, but never API secrets, POST authentication + bodies, or full found-job request targets — sanitize before sharing. + + + Once the first line appears, merge mining is running and there is nothing left to configure. The other two + follow on their own, whenever a share qualifies. + + + ), + }, + { + slug: 'troubleshooting', + title: 'Troubleshooting', + group: 'Support', + content: () => ( + <> + [t.issue, t.check])} /> + + RUST_LOG=debug helps temporarily, but debug output carries extra work and proof metadata and must + follow the same log-handling rules. + + + RSK merge mining is live, and it is opt-in and off by default, so turning it on is your choice. It runs + against the specific reviewed builds named in the prerequisites, and none of it goes near your Bitcoin block + submission path, so if the RSK side ever pauses, your Bitcoin mining keeps running untouched. + + + Keep an eye on the client README. The canonical, always-current merge-mining reference lives at{' '} + github.com/dmnd-pool/dmnd-client. Releases move regularly, so if a flag, port, endpoint, or + commit ever differs from what you read here, the README is the source of truth. + + + ), + }, + { + slug: 'known-limitation', + title: 'Known miner-target limitation', + group: 'Support', + content: () => ( + <> + + The client deliberately never lowers a miner’s normal Bitcoin share difficulty. It evaluates every + authenticated, structurally valid share it receives before the normal Bitcoin-difficulty filter, so an + RSK-valid submitted share is not hidden by a harder upstream filter. + + + An ASIC, however, reports only hashes that satisfy the target assigned to it. If the RSK target is easier than + the miner’s assigned target, some hashes can satisfy RSK while never being submitted by the ASIC. The + client and bridge cannot observe or recover those hashes. + + + This is an intentional stability-first policy: merge mining does not change miner traffic or normal Bitcoin + difficulty. It is a known deviation from a design that guarantees observation of every RSK-valid hash. A + bridge implementation cannot remove this limitation. + + + You have reached the end of the operator guide. Return to{' '} + + Build your block + {' '} + or open the advanced bridge contract only if you are writing or hardening a bridge of your own. + + + Open advanced bridge documentation + + + ), + }, + { + slug: 'http-contract', + title: 'Bridge-facing HTTP contract', + group: 'Advanced', + content: () => ( + <> + + This section and the two that follow define the wire contract a bridge must satisfy. The companion{' '} + demand-rsk-op-return-bridge already implements it, so skip them if you are running that bridge. + Both endpoints use JSON and the envelope: An application error uses: - A bridge MUST treat a non-2xx status, malformed JSON,{' '} - success: false, or missing required success data as a failed call. + A bridge MUST treat a non-2xx status, malformed JSON, success: false, or missing + required success data as a failed call. - + Request fields: @@ -444,7 +659,7 @@ export function MergeMiningPage() { [ secret, <> - Exact value of the proxy’s non-empty API_SECRET + Exact value of the client’s non-empty API_SECRET , ], [ @@ -472,7 +687,9 @@ export function MergeMiningPage() { replaced_pending is true whenever any desired pair was already stored, including an identical - pair. Reposting is valid and does not duplicate a commitment in one template. + pair. Reposting is valid and does not duplicate a commitment in one template. An HTTP{' '} + 202 Accepted does not prove that an RSK job was sent to miners; it means only that the pair is + stored and available for a subsequent compatible NewTemplate. Actual error statuses are: - - Malformed JSON may be rejected by the HTTP framework with another non-success response. - - + An empty queue is successful: @@ -508,8 +722,8 @@ export function MergeMiningPage() { id, 'Positive identifier unique during this proxy process lifetime'], - [observed_at_unix_ts, 'UTC Unix seconds when the proxy observed the share'], + [id, 'Positive identifier unique during this client process lifetime'], + [observed_at_unix_ts, 'UTC Unix seconds when the client observed the share'], [template_id, 'Exact Template Distribution template used for reconstruction'], [version, 'Submitted Bitcoin header version; diagnostic'], [header_timestamp, 'Submitted header timestamp; diagnostic'], @@ -534,24 +748,29 @@ export function MergeMiningPage() { intentionally pop an item. - Delivery is at-most-once. If the HTTP response is lost after the proxy removes the item, the proxy does not - deliver it again. A bridge therefore owns a job as soon as it receives a successful object and{' '} - MUST keep that job in its own bounded retry state - until RskJ accepts it, the job expires, or a terminal error makes it unusable. + Delivery is at-most-once. If the HTTP response is lost after the client removes the item, the client does + not deliver it again. A bridge therefore owns a job as soon as it receives a successful object and{' '} + MUST keep that job in its own bounded retry state until RskJ accepts it, the job expires, or a + terminal error makes it unusable. An ambiguous GET failure must not be treated as a retry of the same queue item: a later GET may pop the next item because the first may already have been removed. The bridge should continue normal polling and accept that the response-lost candidate is unrecoverable. It should also ignore unknown response fields so additive - proxy changes remain compatible. + changes remain compatible. - - - + + ), + }, + { + slug: 'byte-order-and-validation', + title: 'Byte order and proof validation', + group: 'Advanced', + content: () => ( + <> - A production bridge MUST validate a found job before - submitting it to RskJ. At minimum: + A production bridge MUST validate a found job before submitting it to RskJ. At minimum: , 'compute the witness-stripped coinbase txid;', - 'reconstruct the merkle root and compare it with the header; and', + 'reconstruct the merkle root and compare it with the header;', 'validate the transaction count and exact sibling count; and', 'require at most 128 bytes after the selected 32-byte RSK work hash.', ]} /> - The companion demand-rsk-op-return-bridge is interoperable with the current proxy, but it does - not yet perform every independent check above. In particular, it trusts the proxy and RskJ for the + The companion demand-rsk-op-return-bridge is interoperable with the current client, but it does + not yet perform every independent check above. In particular, it trusts the client and RskJ for the header-hash, last-commitment, and reconstructed-merkle-root checks. A new production bridge should not copy - that trust shortcut unless the proxy connection is inside the same trusted failure domain; RskJ rejection + that trust shortcut unless the client connection is inside the same trusted failure domain; RskJ rejection still affects only merge-mining submission and never Bitcoin processing. - - The companion bridge’s pending proof retry VecDeque also has no hard item cap. Job expiry - limits retention time but not the maximum number of retained jobs. It is therefore not production conformant - with this document’s bounded-state requirement until that queue has a hard cap and a documented eviction - policy. This does not consume proxy memory or affect Bitcoin mining. - - + block_header_hex is the normal Bitcoin consensus header: 0..4, 'version', <>little-endian u32], + [ + 0..4, + 'version', + <> + little-endian u32 + , + ], [4..36, 'previous block hash', 'raw Bitcoin header byte order'], [36..68, 'merkle root', 'raw Bitcoin header byte order'], - [68..72, 'timestamp', <>little-endian u32], - [72..76, nBits, <>little-endian u32], - [76..80, 'nonce', <>little-endian u32], + [ + 68..72, + 'timestamp', + <> + little-endian u32 + , + ], + [ + 72..76, + nBits, + <> + little-endian u32 + , + ], + [ + 76..80, + 'nonce', + <> + little-endian u32 + , + ], ]} /> @@ -612,7 +849,7 @@ export function MergeMiningPage() { - + merkle_hashes_hex contains: @@ -634,7 +871,7 @@ export function MergeMiningPage() { - + Let C be the complete witness-stripped consensus serialization of the coinbase and{' '} H the 32-byte work hash from this found job. A compatible producer or validating bridge must @@ -644,14 +881,20 @@ export function MergeMiningPage() { The scan is byte-oriented across all fields and scripts; a marker can therefore occur in a non-OP_RETURN script or span a serialization boundary. Witness bytes are excluded. The bound is inclusive: 128 trailing - bytes pass and 129 fail. The proxy separately requires its intended output to use the canonical OP_RETURN + bytes pass and 129 fail. The client separately requires its intended output to use the canonical OP_RETURN form before it publishes RSK-bound work. - - - - + + ), + }, + { + slug: 'rskj-contract', + title: 'RskJ-facing bridge contract', + group: 'Advanced', + content: () => ( + <> + Call JSON-RPC 2.0: @@ -673,12 +916,12 @@ export function MergeMiningPage() { unusable; do not POST a partial pair. - Only remember a pair as installed after the proxy returns a valid 202 success envelope with all - three metadata fields. Retry failed delivery. Reposting the same pair is safe. + Only remember a pair as installed after the client returns a valid 202 success envelope with + all three metadata fields. Retry failed delivery. Reposting the same pair is safe. - + When {'block_tx_count > 1'}, call: @@ -691,11 +934,11 @@ export function MergeMiningPage() { that work hash; a terminal “work not found” response retires it. - The proxy-to-bridge values remain in standard Bitcoin display order. At the RskJ RPC boundary, the bridge - derives the witness-stripped coinbase txid and byte-reverses it and every proxy-provided sibling into raw - hash order. The sibling order remains bottom-up and unchanged. This compensates for VETIVER’s RSKIP92 - proof builder reversing each submitted value internally. The sibling list from the proxy itself never - contains the coinbase txid. + The client-to-bridge values remain in standard Bitcoin display order. At the RskJ RPC boundary, the bridge + derives the witness-stripped coinbase txid and byte-reverses it and every provided sibling into raw hash + order. The sibling order remains bottom-up and unchanged. This compensates for VETIVER’s RSKIP92 proof + builder reversing each submitted value internally. The sibling list from the client itself never contains + the coinbase txid. The equivalent JSON-RPC params value is: @@ -706,7 +949,7 @@ export function MergeMiningPage() { - + When block_tx_count == 1, construct: @@ -717,12 +960,11 @@ export function MergeMiningPage() { - + After destructive GET, RskJ submission errors belong entirely to the bridge. A production bridge{' '} - MUST hard-bound its locally owned proof queue and - define which job is evicted on overflow. It{' '} - SHOULD also: + MUST hard-bound its locally owned proof queue and define which job is evicted on overflow. It{' '} + SHOULD also: continue fetching newer mnr_getWork while older proof submission is retrying; and , - 'process locally owned proofs even when a later proxy poll fails.', + 'process locally owned proofs even when a later client poll fails.', ]} /> - Use observed_at_unix_ts to expire jobs. Synchronize the bridge and proxy hosts with NTP or + Use observed_at_unix_ts to expire jobs. Synchronize the bridge and client hosts with NTP or chrony. - - - - The proxy keeps the desired pair only in process memory. A bridge that suppresses an unchanged pair after one - successful POST can leave a restarted proxy without RSK work indefinitely. - - - A compatible deployment MUST provide one - resynchronization mechanism: - - - restart the bridge after every full dmnd-client restart; or - , - 'make the bridge periodically repost the current pair; or', - "detect a new proxy process/session and clear the bridge's last-installed cache.", - ]} - /> - - A simple deployment uses separate supervisors and restarts the bridge after the proxy is healthy. Internal - upstream reconnects do not require a repost because the proxy retains the desired pair and clears only - session-scoped bindings. - - - - - - The merge-mining endpoints use a shared secret but provide no TLS. The GET contract places that secret in the - query string. A production deployment must: - - - set API_BIND_ADDRESS=127.0.0.1 when the bridge is on the same host; - , - 'keep the API on a trusted private network or behind an authenticated TLS reverse proxy;', - 'firewall it from the public internet;', - 'avoid logging full GET URLs or query strings;', - <> - use the same strong secret for API_SECRET and the bridge credential; and - , - 'supervise the bridge independently from the proxy.', - ]} - /> - RskJ must be started with: - - Example proxy invocation: - - A bridge may use these configuration names, matching the companion implementation: - RSK_RPC_URL, 'Yes', http://127.0.0.1:4444], - [ - DMND_CLIENT_API_SECRET, - 'Yes', + + + The client keeps the desired pair only in process memory. A bridge that suppresses an unchanged pair after + one successful POST can leave a restarted client without RSK work indefinitely. + + + A compatible deployment MUST provide one resynchronization mechanism: + + - Same value as API_SECRET + restart the bridge after every full dmnd-client restart; or , - ], - [ - DMND_CLIENT_OP_RETURN_URL, - 'No', - http://127.0.0.1:3001/api/coinbase/op-return, - ], - [ - DMND_CLIENT_FOUND_JOB_URL, - 'No', - http://127.0.0.1:3001/api/merge-mining/found-job, - ], - [RSK_POLL_INTERVAL_SECS, 'No', 1], - [FOUND_JOB_POLL_INTERVAL_SECS, 'No', 1], - [JOB_RETRY_INTERVAL_SECS, 'No', 5], - [FOUND_JOB_MAX_AGE_SECS, 'No', 600], - ]} - /> - - These environment-variable names are not part of the wire protocol; another bridge may expose equivalent - configuration differently. - - + 'make the bridge periodically repost the current pair; or', + "detect a new client process/session and clear the bridge's last-installed cache.", + ]} + /> + + A simple deployment uses separate supervisors and restarts the bridge after the client is healthy. Internal + upstream reconnects do not require a repost because the client retains the desired pair and clears only + session-scoped bindings. + + + + ), + }, +]; - - - This proxy deliberately never lowers a miner’s normal Bitcoin share difficulty. It evaluates every - authenticated, structurally valid share it receives before the normal Bitcoin-difficulty filter, so an - RSK-valid submitted share is not hidden by a harder upstream filter. - - - An ASIC, however, reports only hashes that satisfy the target assigned to it. If the RSK target is easier than - the miner’s assigned target, some hashes can satisfy RSK while never being submitted by the ASIC. The - proxy and bridge cannot observe or recover those hashes. - - - This is an intentional stability-first policy: merge mining does not change miner traffic or normal Bitcoin - difficulty. It is a known deviation from a design that guarantees observation of every RSK-valid hash. A - bridge implementation cannot remove this limitation. - - +const sectionPath = (slug: string) => `${BASE_PATH}/${slug}`; - - A bridge is compatible when it verifies all of the following: - - It constructs exactly RSKBLOCK: plus the 32-byte RskJ work hash without reversal. - , - 'It sends the matching 32-byte target in the same POST and never installs half a pair.', - <> - It treats only 202 plus a valid success envelope as successful installation. - , - 'It retries failed pair delivery and provides restart resynchronization.', - <> - It treats data: null from GET as an empty queue, not an error. - , - 'It understands that GET is destructive and retains fetched jobs in bounded local retry state.', - 'It validates header length/hash, target, the canonical output, the last raw RSK tag, the inclusive 128-byte trailing limit, transaction count, merkle path length, and reconstructed merkle root.', - 'It derives the RskJ work hash from each found job and does not mix old proof context with the newest cached pair.', - <> - It submits single-transaction blocks with mnr_submitBitcoinBlock. - , - <> - It submits multi-transaction proofs with the exact RSKIP92 hash order and{' '} - mnr_submitBitcoinBlockPartialMerkle parameters above. - , - 'It retries only transient RskJ failures, expires old work, and bounds rate-limit pressure.', - "It never sends bridge failures back into the proxy's Bitcoin lifecycle.", - 'Operators have verified one declaration, one custom-job request, and one miner job per affected template, with no private capability flag or delayed second flow.', - 'Operators understand and accept the miner-target limitation above.', - ]} - /> - +/** How a section is named in the pager, which has room for the step prefix. */ +const sectionLabel = (s: GuideSection) => { + const name = s.navTitle ?? s.title; + return s.step ? `Step ${s.step}: ${name}` : name; +}; - - src/api/mod.rs], - [ - 'Pair validation, template state, reconstruction, queues and API handlers', - src/merge_mining.rs, - ], - ['Atomic template injection and pristine fallback', src/jd_client/template_receiver/mod.rs], - ['Single ordinary declaration flow', src/jd_client/job_declarator/mod.rs], - ['Exact custom-job response correlation', src/jd_client/mining_upstream/upstream.rs], - [ - 'Miner job binding, chain context and Bitcoin solution isolation', - src/jd_client/mining_downstream/mod.rs, - ], - ['Pre-difficulty, nonblocking share observation', src/translator/downstream/downstream.rs], - ]} - /> - - The companion implementation and its deeper behavioral test specification live at: - - - +const STEP_COUNT = SECTIONS.filter((s) => s.step).length; + +/** The one group held back until a reader asks for it. */ +const ADVANCED_GROUP = 'Advanced'; +const OPERATOR_SECTIONS = SECTIONS.filter((section) => section.group !== ADVANCED_GROUP); +const ADVANCED_SECTIONS = SECTIONS.filter((section) => section.group === ADVANCED_GROUP); + +/** + * Merge mining in dmnd: what you get, how to turn it on, and the wire contract for a + * bridge of your own. + * + */ +export function MergeMiningPage() { + const [, params] = useRoute(`${BASE_PATH}/:section`); + // Setting this up spans days, so a finished step stays finished across visits. + const { ticked, toggle } = useGuideTicks('merge-mining-steps'); + const found = SECTIONS.findIndex((s) => s.slug === params?.section); + const index = found === -1 ? 0 : found; + const section = SECTIONS[index]; + // The normal operator journey deliberately ends after its support material. Advanced + // bridge-contract pages form their own small sequence and are never a surprise Next. + const pagerSections = section.group === ADVANCED_GROUP ? ADVANCED_SECTIONS : OPERATOR_SECTIONS; + const pagerIndex = pagerSections.findIndex((candidate) => candidate.slug === section.slug); + const prev = pagerSections[pagerIndex - 1]; + const next = pagerSections[pagerIndex + 1]; + + const root = useRef(null); + useEffect(() => { + root.current?.closest('main')?.scrollTo({ top: 0 }); + }, [index]); + + return ( +
    + + {index === 0 && } + +
    + ({ + href: sectionPath(s.slug), + label: s.navTitle ?? s.title, + group: s.group, + step: s.step, + done: s.step !== undefined && ticked.includes(s.slug), + active: s.slug === section.slug, + }))} + /> + +
    + + + {section.content()} + + {section.step !== undefined && ( + <> + + toggle(section.slug)} /> + + )} + + - - + +
    +
    +
    +
    ); } diff --git a/src/pages/build-your-block/PrioritizeTransactionsPage.tsx b/src/pages/build-your-block/PrioritizeTransactionsPage.tsx index e17078e..e5e5875 100644 --- a/src/pages/build-your-block/PrioritizeTransactionsPage.tsx +++ b/src/pages/build-your-block/PrioritizeTransactionsPage.tsx @@ -1,26 +1,39 @@ -import heroImage from '@/assets/guide-hero-prioritize-transactions.png'; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Link, useRoute } from "wouter"; +import { cn } from "@/lib/utils"; +import heroImage from "@/assets/guide-hero-prioritize-transactions.png"; import { DocHero, - DocPage, DocSection, + DocSectionHeader, + DocSectionNav, + DocPager, DocText, - DocList, + DocLede, DocTable, DocCallout, - CodeBlock, + DocMarkedList, Chip, DocFooterPrompt, -} from '@/components/docs/DocPrimitives'; +} from "@/components/docs/DocPrimitives"; +import { + DocPage, + StackSummary, + CodeBlock, + CommandLegend, +} from "@/components/docs/DocGuide"; -const DMND_CLIENT_REPO = 'https://github.com/dmnd-pool/dmnd-client'; +const DMND_CLIENT_PRIORITIZATION = + "https://github.com/dmnd-pool/dmnd-client#7-prioritize-transactions-optional"; +const JOB_DECLARATION_PATH = "/build-your-block/job-declaration"; -const ENV_EXAMPLE = `TOKEN= \\ -RPC_URL=http://127.0.0.1:8332 \\ -RPC_USER= \\ -RPC_PWD= \\ -RPC_FEE_DELTA=100000 \\ -API_TX_TOKEN= \\ -./dmnd-client -l info -d 250T --tp-address="127.0.0.1:8336"`; +const CLI_EXAMPLE = `./dmnd-client -l info -d 250T --tp-address="127.0.0.1:8336" \\ + --token \\ + --rpc-url http://127.0.0.1:8332 \\ + --rpc-user \\ + --rpc-pwd \\ + --rpc-fee-delta 100000 \\ + --api-tx-token `; const TOML_EXAMPLE = `rpc_url = "http://127.0.0.1:8332" rpc_user = "" @@ -54,70 +67,211 @@ const RESPONSE_EXAMPLE = `{ } }`; -const SETTINGS: { setting: string; flag: string; toml: string; env: string; description: React.ReactNode }[] = [ +interface Setting { + setting: string; + flag: string; + toml: string; + env: string; + description: React.ReactNode; +} + +const SETTINGS: Setting[] = [ { - setting: 'RPC URL', - flag: '--rpc-url', - toml: 'rpc_url', - env: 'RPC_URL', + setting: "RPC URL", + flag: "--rpc-url", + toml: "rpc_url", + env: "RPC_URL", description: ( <> Bitcoin Core RPC, e.g. http://127.0.0.1:8332 ), }, - { setting: 'RPC user', flag: '--rpc-user', toml: 'rpc_user', env: 'RPC_USER', description: 'Bitcoin Core RPC username' }, - { setting: 'RPC password', flag: '--rpc-pwd', toml: 'rpc_pwd', env: 'RPC_PWD', description: 'Bitcoin Core RPC password' }, { - setting: 'Fee delta', - flag: '--rpc-fee-delta', - toml: 'rpc_fee_delta', - env: 'RPC_FEE_DELTA', + setting: "RPC user", + flag: "--rpc-user", + toml: "rpc_user", + env: "RPC_USER", + description: "Bitcoin Core RPC username", + }, + { + setting: "RPC password", + flag: "--rpc-pwd", + toml: "rpc_pwd", + env: "RPC_PWD", + description: "Bitcoin Core RPC password", + }, + { + setting: "Fee delta", + flag: "--rpc-fee-delta", + toml: "rpc_fee_delta", + env: "RPC_FEE_DELTA", description: ( <> - Virtual fee boost in satoshis, passed to prioritisetransaction + Virtual fee boost in satoshis, passed to{" "} + prioritisetransaction ), }, - { setting: 'API token', flag: '--api-tx-token', toml: 'api_tx_token', env: 'API_TX_TOKEN', description: 'Bearer token required by this API' }, + { + setting: "API token", + flag: "--api-tx-token", + toml: "api_tx_token", + env: "API_TX_TOKEN", + description: "Bearer token required by this API", + }, ]; -/** - * Prioritize transactions: an optional DMND Client API that asks the miner's own - * Bitcoin Core node to favour a transaction when building templates. The boost is - * virtual, so it spends nothing and does not alter the transaction on the network. - */ -export function PrioritizeTransactionsPage() { +function ConfigureBy({ aside }: { aside: ReactNode }) { + // Ordered by precedence: a CLI flag beats the config file, which beats the environment. + const [method, setMethod] = useState<"cli" | "toml" | "env">("cli"); + + const methods = [ + { id: "cli" as const, label: "CLI flag", code: CLI_EXAMPLE }, + { id: "toml" as const, label: "config.toml", code: TOML_EXAMPLE }, + // Nothing to run for this one: the names are the whole answer, so it shows the table. + { id: "env" as const, label: "Env var", code: null }, + ]; + const open = methods.find((m) => m.id === method)!; + return ( - - - - - The DMND Client can expose an API endpoint that submits a raw transaction to your Bitcoin Core node and asks it - to prioritize that transaction for block template selection (via the prioritisetransaction RPC). - - - - The feature is enabled only when all of the following are configured: - [ - s.setting, - {s.flag}, - {s.toml}, - {s.env}, - s.description, - ])} - /> - - - RPC_FEE_DELTA is denominated in satoshis. It's a virtual fee adjustment used only for template - selection on your node — it doesn't spend anything — but set it deliberately. 100000 (0.001 BTC - virtual boost) is a reasonable starting point. +
    +
    + {methods.map((m) => ( + + ))} +
    + +
    +
    + {open.code ? ( + <> + + + + ) : ( + [ + setting.setting, + {setting.env}, + setting.description, + ])} + /> + )} +
    +
    {aside}
    +
    +
    + ); +} + +const GUIDE_NAME = "Prioritize transactions"; +const BUILD_YOUR_BLOCK = "BUILD YOUR BLOCK"; +const BUILD_YOUR_BLOCK_PAGE = "/build-your-block"; +const BASE_PATH = "/build-your-block/prioritize-transactions"; + +interface GuideSection { + slug: string; + title: string; + navTitle?: string; + group: string; + step?: number; + content: () => ReactNode; +} + +const SECTIONS: GuideSection[] = [ + { + slug: "set-it-up", + title: "Add prioritization to your existing DMND Client", + navTitle: "Set it up", + group: "Prioritize transactions", + step: 1, + content: () => ( + <> + + Turn control of your block template into a service. Favor an eligible + transaction in the template your own node builds—for fee recovery, + private inclusion, or a paid inclusion workflow. + + + + This is the practical payoff of building your own blocks. Set up{" "} + + Job Declaration + {" "} + first so the template belongs to your operation rather than the pool. - - + +
    + + + + The DMND Client exposes an API that submits a raw transaction to + your Bitcoin Core node and asks it to prioritize that + transaction for local block-template selection through the{" "} + prioritisetransaction RPC. + , + "The virtual fee boost affects only template selection on your node; it does not spend anything or alter the transaction on the network.", + "It is not a confirmation guarantee. The transaction still has to be valid, accepted by your node, selected into a template, and ultimately mined.", + "The endpoint is optional and off by default, so it does not affect normal DMND Client operation unless you enable it.", + ]} + /> + + + + + Keep your existing Job Declaration launch configuration and add all + five settings below. You can set them by CLI flag,{" "} + config.toml, or environment variable; CLI flags take + precedence over the config file, which takes precedence over + environment variables. + + + The feature is enabled only when every setting is configured: + + + + RPC_FEE_DELTA is denominated in satoshis. It's a + virtual fee adjustment used only for template selection on + your node — it doesn't spend anything 100000{" "} + (0.001 BTC virtual boost) is a reasonable starting point. + + + + } + /> + + + + The tx API (default port 3001) should never be exposed to the public internet. Bind it to @@ -132,43 +286,137 @@ export function PrioritizeTransactionsPage() { , ]} /> - - - - - - - - - - - - + + + ), + }, + { + slug: "using-the-api", + title: "Using the API", + group: "Prioritize transactions", + step: 2, + content: () => ( + <> Submit a raw transaction hex: List currently tracked prioritized transactions: + + After submitting a valid transaction, the list endpoint returns it + with a modified fee above the transaction’s{" "} + real base fee. That confirms your node is tracking the + local priority. + + - The response includes the tracked transaction count, transaction hex, and live mempool fees from Bitcoin Core —{' '} - tx_fee.real is getmempoolentry's fees.base;{' '} - tx_fee.modified is the boosted fees.modified: + The response includes the tracked transaction count, transaction hex, + and live mempool fees from Bitcoin Core — tx_fee.real is{" "} + getmempoolentry's fees.base;{" "} + tx_fee.modified is the boosted fees.modified + : - The API server port defaults to 3001 and can be changed with --api-server-port,{' '} - api_server_port, or API_SERVER_PORT. + The API server port defaults to 3001 and can be changed + with --api-server-port, api_server_port, or{" "} + API_SERVER_PORT. - If the prioritization configuration is incomplete, these endpoints are disabled: the client logs that - transaction prioritization is not enabled and the endpoints return 503 Service Unavailable. + If the prioritization configuration is incomplete, these endpoints are + disabled: the client logs that transaction prioritization is not + enabled and the endpoints return 503 Service Unavailable. -
    + + ), + }, +]; + +const STEP_COUNT = SECTIONS.filter((s) => s.step).length; +const sectionPath = (slug: string) => `${BASE_PATH}/${slug}`; +const sectionLabel = (s: GuideSection) => { + const name = s.navTitle ?? s.title; + return s.step ? `Step ${s.step}: ${name}` : name; +}; + +/** + * Prioritize transactions: an optional DMND Client API that asks the miner's own + * Bitcoin Core node to favour a transaction when building templates. The boost is + * virtual, so it spends nothing and does not alter the transaction on the network. + */ +export function PrioritizeTransactionsPage() { + const [, params] = useRoute(`${BASE_PATH}/:section`); + const found = SECTIONS.findIndex((s) => s.slug === params?.section); + const index = found === -1 ? 0 : found; + const section = SECTIONS[index]; + const prev = SECTIONS[index - 1]; + const next = SECTIONS[index + 1]; + + // Paging is a fresh page, not a scroll. The dashboard scrolls its
    , not the window. + const root = useRef(null); + useEffect(() => { + root.current?.closest("main")?.scrollTo({ top: 0 }); + }, [index]); + + return ( +
    + + {index === 0 && } + +
    + ({ + href: sectionPath(s.slug), + label: s.navTitle ?? s.title, + group: s.group, + step: s.step, + active: s.slug === section.slug, + }))} + /> + +
    + + + {section.content()} + + - - + +
    +
    +
    +
    ); }
    + {h}
    + {cell}