From e7527cf09b0104546fc11d54901ed4b19d82e6b0 Mon Sep 17 00:00:00 2001 From: mrv777 Date: Tue, 16 Jun 2026 09:55:10 -0500 Subject: [PATCH 1/5] feat: support manual (self-supplied) wallets Add a `manual` wallet type alongside Xverse so users with wallets that have no browser extension (e.g. Sparrow) can connect by entering their address and approve actions by pasting BIP322 signatures from their own wallet. - Centralize the six duplicated `request('signMessage')` call sites behind one `signMessage()` primitive on the wallet context. Xverse signs via sats-connect; manual surfaces the message in a global modal and resolves once the user pastes a signature. - Add `walletType` + `connectManual` to the wallet context; manual wallets persist address-only (no public key, no Lightning). - Connect stays Xverse-direct; manual is reachable via a "Use another wallet" link and a "Connect manually" entry in the help modal. - Decouple owner gating so manual owners see Stratum + Dispenser + privacy toggle by ownership, with no Lightning balance and no Refinery. Xverse flow is unchanged. - Dispenser claim gains a manual path: enter a destination Ordinals address, then sign the claim message manually. Add ManualSignModal and ManualConnectModal. --- app/components/ConnectButton.tsx | 86 ++++--- app/components/LightningBalance.tsx | 23 +- app/components/dispenser/DispenserClaim.tsx | 227 +++++++++++++++---- app/components/modals/HelpModal.tsx | 15 +- app/components/modals/LightningModal.tsx | 25 +- app/components/modals/ManualConnectModal.tsx | 150 ++++++++++++ app/components/modals/ManualSignModal.tsx | 154 +++++++++++++ app/hooks/useWallet.tsx | 171 ++++++++++---- app/user/[id]/page.tsx | 74 +++--- 9 files changed, 732 insertions(+), 193 deletions(-) create mode 100644 app/components/modals/ManualConnectModal.tsx create mode 100644 app/components/modals/ManualSignModal.tsx diff --git a/app/components/ConnectButton.tsx b/app/components/ConnectButton.tsx index d6b5ee2..981e5cb 100644 --- a/app/components/ConnectButton.tsx +++ b/app/components/ConnectButton.tsx @@ -4,15 +4,22 @@ import { useState, useRef, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useWallet } from '../hooks/useWallet'; import HelpModal from './modals/HelpModal'; +import ManualConnectModal from './modals/ManualConnectModal'; export default function ConnectButton() { const { address, isConnected, disconnect, connectWithLightning } = useWallet(); const [showDropdown, setShowDropdown] = useState(false); const [showHelpModal, setShowHelpModal] = useState(false); + const [showManualConnect, setShowManualConnect] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const dropdownRef = useRef(null); const router = useRouter(); + const openManualConnect = () => { + setShowHelpModal(false); + setShowManualConnect(true); + }; + // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -94,42 +101,59 @@ export default function ConnectButton() { return ( <> -
- + + {isConnected && showDropdown && ( +
+ + +
)} - +
- {isConnected && showDropdown && ( -
- - -
+ {!isConnected && !isConnecting && ( + )} - setShowHelpModal(false)} + setShowHelpModal(false)} + onManualConnect={openManualConnect} + /> + + setShowManualConnect(false)} /> ); diff --git a/app/components/LightningBalance.tsx b/app/components/LightningBalance.tsx index 491433e..ec7673f 100644 --- a/app/components/LightningBalance.tsx +++ b/app/components/LightningBalance.tsx @@ -35,6 +35,7 @@ export default function LightningBalance({ address, addressPublicKey, connectWithLightning, + signMessage, } = useWallet(); const [balance, setBalance] = useState(null); const [walletInfo, setWalletInfo] = useState(null); @@ -130,28 +131,12 @@ export default function LightningBalance({ setError(null); try { - // Request signature for the Lightning address using BIP322 - const { request, MessageSigningProtocols } = await import('@sats-connect/core'); - - const signResponse = await request('signMessage', { - address: address, + // Request signature for the Lightning address (BIP322) + const signature = await signMessage({ + address, message: usernameAddress, - protocol: MessageSigningProtocols.BIP322 }); - if (signResponse.status !== 'success') { - throw new Error('Failed to sign message'); - } - - let signature: string; - if (typeof signResponse.result === 'string') { - signature = signResponse.result; - } else if (signResponse.result && typeof signResponse.result === 'object' && 'signature' in signResponse.result) { - signature = signResponse.result.signature; - } else { - throw new Error('Unexpected signature format'); - } - // Send update request const response = await fetch('/api/account/update', { method: 'POST', diff --git a/app/components/dispenser/DispenserClaim.tsx b/app/components/dispenser/DispenserClaim.tsx index b53a02c..3afcb0a 100644 --- a/app/components/dispenser/DispenserClaim.tsx +++ b/app/components/dispenser/DispenserClaim.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, type MouseEvent } from "react"; import Image from "next/image"; import { useWallet } from "@/app/hooks/useWallet"; +import { isValidBitcoinAddress } from "@/app/utils/validators"; import { getCollapsibleContainerClassName, shouldToggleCollapse } from "@/app/components/collapsible"; interface Eligibility { @@ -31,6 +32,24 @@ interface DispenserClaimProps { onToggle?: () => void; } +function buildClaimMessage( + username: string, + tier: string, + tierSlotIndex: number, + destinationAddress: string, +): string { + return `${username}|${tier}|${tierSlotIndex}|${destinationAddress}`; +} + +// Surface real failures but stay quiet when the user simply cancels signing. +function getClaimErrorMessage(err: unknown): string | null { + const message = err instanceof Error ? err.message : "Failed to claim"; + if (message.toLowerCase().includes("cancel")) { + return null; + } + return message; +} + function buildSlots(data: Eligibility): Slot[] { const slots: Slot[] = []; for (const [tier, inscriptionIds] of Object.entries(data.assigned_inscription_ids ?? {})) { @@ -51,7 +70,7 @@ function buildSlots(data: Eligibility): Slot[] { } export default function DispenserClaim({ userId, className = "", collapsed = false, onToggle }: DispenserClaimProps) { - const { address, isInitialized } = useWallet(); + const { address, walletType, isInitialized, signMessage } = useWallet(); const [eligibility, setEligibility] = useState(null); const [loading, setLoading] = useState(true); const [claimingSlot, setClaimingSlot] = useState(null); @@ -59,8 +78,13 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal const [error, setError] = useState(null); // const [txHex, setTxHex] = useState(null); const [copiedSlot, setCopiedSlot] = useState(null); + // Manual wallets can't auto-supply an Ordinals address, so they enter a + // destination address before signing the claim. + const [manualSlot, setManualSlot] = useState(null); + const [manualDestination, setManualDestination] = useState(""); const isOwner = address === userId; + const isManual = walletType === "manual"; const handleCopyLink = async (inscriptionId: string, slotIndex: number) => { const url = `${window.location.origin}/dispenser/share/${inscriptionId}`; @@ -96,6 +120,33 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal } }, [isInitialized, fetchEligibility]); + const submitClaim = useCallback(async ( + tier: string, + tierSlotIndex: number, + destinationAddress: string, + signature: string, + ) => { + const response = await fetch("/api/dispenser/claim", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: userId, + tier, + slot: tierSlotIndex, + destination_address: destinationAddress, + signature, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || "Failed to submit claim"); + } + + return data; + }, [userId]); + const handleClaim = async (tier: string, slotIndex: number, tierSlotIndex: number) => { if (!address) return; @@ -104,7 +155,7 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal // setTxHex(null); try { - const { request, MessageSigningProtocols, AddressPurpose } = await import("@sats-connect/core"); + const { request, AddressPurpose } = await import("@sats-connect/core"); const accountsResponse = await request("getAccounts", { purposes: [AddressPurpose.Ordinals], @@ -124,57 +175,77 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal } const destinationAddress = ordinalsAccount.address; - const message = `${userId}|${tier}|${tierSlotIndex}|${destinationAddress}`; + const message = buildClaimMessage(userId, tier, tierSlotIndex, destinationAddress); - const signResponse = await request("signMessage", { - address: address, - message: message, - protocol: MessageSigningProtocols.BIP322, - }); + const signature = await signMessage({ address, message }); - if (signResponse.status !== "success") { - throw new Error("Failed to sign message"); - } + await submitClaim(tier, tierSlotIndex, destinationAddress, signature); - let signature: string; - if ( - signResponse.result && - typeof signResponse.result === "object" && - "signature" in signResponse.result - ) { - signature = signResponse.result.signature; - } else { - throw new Error("Unexpected signature format"); - } + // setTxHex(data.hex); + setLocalClaimed((prev) => new Set(prev).add(slotIndex)); + } catch (err) { + console.error("Claim error:", err); + setError(getClaimErrorMessage(err)); + } finally { + setClaimingSlot(null); + } + }; - const response = await fetch("/api/dispenser/claim", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - username: userId, - tier, - slot: tierSlotIndex, - destination_address: destinationAddress, - signature, - }), - }); + const openManualClaim = (slot: Slot) => { + setManualSlot(slot); + setManualDestination(""); + setError(null); + }; - const data = await response.json(); + const closeManualClaim = useCallback(() => { + if (claimingSlot !== null) return; + setManualSlot(null); + setManualDestination(""); + }, [claimingSlot]); - if (!response.ok) { - throw new Error(data.error || "Failed to submit claim"); - } + const handleManualClaim = async () => { + if (!manualSlot) return; - // setTxHex(data.hex); - setLocalClaimed((prev) => new Set(prev).add(slotIndex)); + const destinationAddress = manualDestination.trim(); + if (!isValidBitcoinAddress(destinationAddress)) { + setError("Enter a valid destination Bitcoin address"); + return; + } + + const slot = manualSlot; + setClaimingSlot(slot.index); + setError(null); + // Close the destination prompt so the signing modal is visible. + setManualSlot(null); + + try { + const message = buildClaimMessage(userId, slot.tier, slot.tierSlotIndex, destinationAddress); + + const signature = await signMessage({ address: userId, message }); + + await submitClaim(slot.tier, slot.tierSlotIndex, destinationAddress, signature); + + setLocalClaimed((prev) => new Set(prev).add(slot.index)); + setManualDestination(""); } catch (err) { - console.error("Claim error:", err); - setError(err instanceof Error ? err.message : "Failed to claim"); + console.error("Manual claim error:", err); + setError(getClaimErrorMessage(err)); } finally { setClaimingSlot(null); } }; + useEffect(() => { + if (!manualSlot) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === "Escape") closeManualClaim(); + }; + + window.addEventListener("keydown", handleEscKey); + return () => window.removeEventListener("keydown", handleEscKey); + }, [manualSlot, closeManualClaim]); + // Don't render anything while loading or if not eligible if (loading || !eligibility) return null; @@ -244,7 +315,9 @@ export default function DispenserClaim({ userId, className = "", collapsed = fal )} {isOwner && !slot.claimed && ( + + +
+
+ Enter the Ordinals address where the inscription should be sent. You'll then + sign a message with your mining address to authorize the claim. +
+ +
+ + setManualDestination(event.target.value)} + placeholder="bc1p..." + autoFocus + className="w-full bg-secondary text-foreground px-3 py-2 border border-border focus:outline-none focus:border-accent-3 font-mono text-sm" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + + )} ); } diff --git a/app/components/modals/HelpModal.tsx b/app/components/modals/HelpModal.tsx index c09d8a9..ee5e5b8 100644 --- a/app/components/modals/HelpModal.tsx +++ b/app/components/modals/HelpModal.tsx @@ -6,9 +6,10 @@ import MinerSetupGuide from '../MinerSetupGuide'; interface HelpModalProps { isOpen: boolean; onClose: () => void; + onManualConnect?: () => void; } -export default function HelpModal({ isOpen, onClose }: HelpModalProps) { +export default function HelpModal({ isOpen, onClose, onManualConnect }: HelpModalProps) { // Close modal on escape key useEffect(() => { const handleEscKey = (e: KeyboardEvent) => { @@ -60,10 +61,18 @@ export default function HelpModal({ isOpen, onClose }: HelpModalProps) {
Participation in Bitcoin mining, including through Parasite Pool, which is still considered in beta testing, involves risks such as market volatility, hardware failure, and changes in network difficulty. Parasite Pool is in beta and has not yet found a block; there is no assurance of future block discoveries or payouts. Users should exercise caution and consider their financial situation before engaging in mining activities.
-
+
+ {onManualConnect && ( + + )} diff --git a/app/components/modals/LightningModal.tsx b/app/components/modals/LightningModal.tsx index aad8c17..d8fc763 100644 --- a/app/components/modals/LightningModal.tsx +++ b/app/components/modals/LightningModal.tsx @@ -11,7 +11,7 @@ interface LightningModalProps { } export default function LightningModal({ isOpen, onClose, onUpdate }: LightningModalProps) { - const { address, lightningToken } = useWallet(); + const { address, lightningToken, signMessage } = useWallet(); const [accountData, setAccountData] = useState(null); const [walletInfo, setWalletInfo] = useState(null); const [balance, setBalance] = useState(null); @@ -107,29 +107,12 @@ export default function LightningModal({ isOpen, onClose, onUpdate }: LightningM setError(null); try { - // Request signature for the Lightning address using BIP322 - // We'll use the wallet's signMessage functionality - const { request, MessageSigningProtocols } = await import('@sats-connect/core'); - - const signResponse = await request('signMessage', { - address: address, + // Request signature for the Lightning address (BIP322) + const signature = await signMessage({ + address, message: newLnAddress, - protocol: MessageSigningProtocols.BIP322 }); - if (signResponse.status !== 'success') { - throw new Error('Failed to sign message'); - } - - let signature: string; - if (typeof signResponse.result === 'string') { - signature = signResponse.result; - } else if (signResponse.result && typeof signResponse.result === 'object' && 'signature' in signResponse.result) { - signature = signResponse.result.signature; - } else { - throw new Error('Unexpected signature format'); - } - // Send update request const response = await fetch('/api/account/update', { method: 'POST', diff --git a/app/components/modals/ManualConnectModal.tsx b/app/components/modals/ManualConnectModal.tsx new file mode 100644 index 0000000..715ee3d --- /dev/null +++ b/app/components/modals/ManualConnectModal.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useWallet } from '@/app/hooks/useWallet'; +import { isValidBitcoinAddress } from '@/app/utils/validators'; + +interface ManualConnectModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function ManualConnectModal({ isOpen, onClose }: ManualConnectModalProps) { + const { connectManual } = useWallet(); + const router = useRouter(); + const [manualAddress, setManualAddress] = useState(''); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState(null); + + // Reset state when the modal closes + useEffect(() => { + if (!isOpen) { + setManualAddress(''); + setIsConnecting(false); + setError(null); + } + }, [isOpen]); + + useEffect(() => { + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + + if (isOpen) { + window.addEventListener('keydown', handleEscKey); + } + + return () => window.removeEventListener('keydown', handleEscKey); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const trimmedAddress = manualAddress.trim(); + + const handleConnect = async () => { + if (!isValidBitcoinAddress(trimmedAddress)) { + setError('Enter a valid Bitcoin address'); + return; + } + + setIsConnecting(true); + setError(null); + + try { + const result = await connectManual(trimmedAddress); + if (result) { + onClose(); + router.push(`/user/${result}`); + } else { + setError('Enter a valid Bitcoin address'); + } + } catch (err) { + console.error('Manual connect error:', err); + setError(err instanceof Error ? err.message : 'Failed to connect'); + } finally { + setIsConnecting(false); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !isConnecting) { + handleConnect(); + } + }; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) onClose(); + }; + + return ( +
+
event.stopPropagation()} + > +
+

Connect a Wallet

+ +
+ +
+
+ Enter the Bitcoin address you mine with. You'll approve actions (like dispenser claims + or the privacy toggle) by signing a message with your own wallet, such as Sparrow. +
+ +
+ + setManualAddress(event.target.value)} + onKeyDown={handleKeyDown} + placeholder="bc1q..." + autoFocus + disabled={isConnecting} + className="w-full bg-secondary text-foreground px-3 py-2 border border-border focus:outline-none focus:border-accent-3 font-mono text-sm disabled:opacity-50" + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/app/components/modals/ManualSignModal.tsx b/app/components/modals/ManualSignModal.tsx new file mode 100644 index 0000000..c442f2b --- /dev/null +++ b/app/components/modals/ManualSignModal.tsx @@ -0,0 +1,154 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export interface ManualSignRequest { + message: string; + address: string | null; +} + +interface ManualSignModalProps { + request: ManualSignRequest | null; + onSubmit: (signature: string) => void; + onCancel: () => void; +} + +export default function ManualSignModal({ request, onSubmit, onCancel }: ManualSignModalProps) { + const [signature, setSignature] = useState(''); + const [copied, setCopied] = useState(false); + + // Reset fields whenever a new signing request opens + useEffect(() => { + setSignature(''); + setCopied(false); + }, [request]); + + useEffect(() => { + if (!request) return; + + const handleEscKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onCancel(); + }; + + window.addEventListener('keydown', handleEscKey); + return () => window.removeEventListener('keydown', handleEscKey); + }, [request, onCancel]); + + if (!request) return null; + + const trimmedSignature = signature.trim(); + + const handleCopyMessage = async () => { + try { + await navigator.clipboard.writeText(request.message); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + const handleSubmit = () => { + if (!trimmedSignature) return; + onSubmit(trimmedSignature); + }; + + const handleBackdropClick = (event: React.MouseEvent) => { + if (event.target === event.currentTarget) onCancel(); + }; + + return ( +
+
event.stopPropagation()} + > +
+
+

Sign Message

+ {request.address && ( +
+ Sign with address + + {request.address} + +
+ )} +
+ +
+ +
+
+

Sign the message below with the wallet that owns the address shown, then paste the signature.

+

+ In Sparrow (or another wallet) use its message-signing feature to produce a BIP322 signature. +

+
+ +
+
+ + +
+