From c139bfcf983aae6d5aaa17cbae49b2529d4a592b Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 17 Feb 2026 10:02:45 +0100 Subject: [PATCH 01/42] feat(frontend): add direct Leather wallet RPC integration - Use LeatherProvider.request('getAddresses') when Leather is available - Use stx_callContract and stx_transferStx for Leather users - Bypass deprecated Stacks Connect legacy flow for Leather - Fall back to Stacks Connect for Xverse and other wallets - Fixes Register and Connect not working with Leather --- frontend/context/StacksContext.tsx | 137 ++++++++++++++++++++--------- frontend/lib/leather.ts | 62 +++++++++++++ 2 files changed, 155 insertions(+), 44 deletions(-) create mode 100644 frontend/lib/leather.ts diff --git a/frontend/context/StacksContext.tsx b/frontend/context/StacksContext.tsx index 8993a5d..091f46c 100644 --- a/frontend/context/StacksContext.tsx +++ b/frontend/context/StacksContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; import { AppConfig, UserSession, @@ -9,6 +9,12 @@ import { openSTXTransfer, } from "@stacks/connect"; import { StacksMainnet } from "@stacks/network"; +import { + isLeatherAvailable, + leatherGetAddresses, + leatherCallContract, + leatherTransferStx, +} from "@/lib/leather"; export interface ContractCallBase { contractAddress: string; @@ -19,23 +25,33 @@ export interface ContractCallBase { interface StacksContextType { address: string | null; - handleConnect: () => void; + handleConnect: () => void | Promise; handleDisconnect: () => void; - callContract: (options: ContractCallBase) => void; - transferStx: (recipient: string, amountMicroStx: bigint) => void; + callContract: (options: ContractCallBase) => void | Promise; + transferStx: (recipient: string, amountMicroStx: bigint) => void | Promise; } const StacksContext = createContext(undefined); +const LEATHER_KEY = "s-pay-wallet-leather"; + export function StacksProvider({ children }: { children: React.ReactNode }) { const [address, setAddress] = useState(null); + const [useLeather, setUseLeather] = useState(false); const appConfig = new AppConfig(["store_write", "publish_data"]); const userSession = new UserSession({ appConfig }); const network = new StacksMainnet(); useEffect(() => { - if (userSession.isUserSignedIn()) { + const stored = typeof window !== "undefined" && sessionStorage.getItem(LEATHER_KEY); + if (stored === "true" && isLeatherAvailable()) { + leatherGetAddresses().then((addr) => { + if (addr) setAddress(addr); + else sessionStorage.removeItem(LEATHER_KEY); + }); + setUseLeather(true); + } else if (userSession.isUserSignedIn()) { const data = userSession.loadUserData(); const addr = data?.profile?.stxAddress?.mainnet ?? data?.profile?.stxAddress?.testnet; @@ -43,50 +59,83 @@ export function StacksProvider({ children }: { children: React.ReactNode }) { } }, []); - const handleConnect = () => { - showConnect({ - appDetails: { - name: "S-pay", - icon: typeof window !== "undefined" ? window.location.origin + "/favicon.ico" : "", - }, - redirectTo: "/", - onFinish: () => { - const data = userSession.loadUserData(); - const addr = - data?.profile?.stxAddress?.mainnet ?? data?.profile?.stxAddress?.testnet; - setAddress(addr ?? null); - }, - onCancel: () => {}, - userSession, - }); - }; + const handleConnect = useCallback(async () => { + if (isLeatherAvailable()) { + try { + const addr = await leatherGetAddresses(); + if (addr) { + setAddress(addr); + setUseLeather(true); + sessionStorage.setItem(LEATHER_KEY, "true"); + } + } catch (err) { + console.error("Leather connect failed:", err); + } + } else { + showConnect({ + appDetails: { + name: "S-pay", + icon: typeof window !== "undefined" ? window.location.origin + "/favicon.ico" : "", + }, + redirectTo: "/", + onFinish: () => { + const data = userSession.loadUserData(); + const addr = + data?.profile?.stxAddress?.mainnet ?? data?.profile?.stxAddress?.testnet; + setAddress(addr ?? null); + }, + onCancel: () => {}, + userSession, + }); + } + }, []); - const handleDisconnect = () => { + const handleDisconnect = useCallback(() => { + sessionStorage.removeItem(LEATHER_KEY); + setUseLeather(false); userSession.signUserOut(); setAddress(null); - }; + }, []); + + const callContract = useCallback( + async (options: ContractCallBase) => { + const contract = `${options.contractAddress}.${options.contractName}`; + const args = options.functionArgs as import("@stacks/transactions").ClarityValue[]; - const callContract = (options: ContractCallBase) => { - openContractCall({ - ...options, - functionArgs: options.functionArgs as (string | import("@stacks/transactions").ClarityValue)[], - userSession, - network, - onFinish: () => {}, - onCancel: () => {}, - }); - }; + if (useLeather && isLeatherAvailable()) { + await leatherCallContract(contract, options.functionName, args); + } else { + openContractCall({ + ...options, + functionArgs: args, + userSession, + network, + onFinish: () => {}, + onCancel: () => {}, + }); + } + }, + [useLeather, userSession, network] + ); - const transferStx = (recipient: string, amountMicroStx: bigint) => { - openSTXTransfer({ - recipient, - amount: amountMicroStx, - userSession, - network, - onFinish: () => {}, - onCancel: () => {}, - }); - }; + const transferStx = useCallback( + (recipient: string, amountMicroStx: bigint) => { + const amount = amountMicroStx.toString(); + if (useLeather && isLeatherAvailable()) { + leatherTransferStx(recipient, amount); + } else { + openSTXTransfer({ + recipient, + amount: amountMicroStx, + userSession, + network, + onFinish: () => {}, + onCancel: () => {}, + }); + } + }, + [useLeather, userSession, network] + ); return ( ) => Promise; + }; + } +} + +export function isLeatherAvailable(): boolean { + return typeof window !== "undefined" && !!window.LeatherProvider; +} + +export async function leatherGetAddresses(): Promise { + if (!isLeatherAvailable()) return null; + try { + const res = await window.LeatherProvider!.request("getAddresses"); + const data = res as Record; + const addrs = (data?.result as Record)?.addresses ?? + data?.addresses ?? []; + const list = Array.isArray(addrs) ? addrs : []; + const stx = list.find((a: Record) => a?.symbol === "STX"); + return (stx?.address as string) ?? null; + } catch { + return null; + } +} + +export async function leatherCallContract( + contract: string, + functionName: string, + functionArgs: ClarityValue[] +): Promise { + if (!isLeatherAvailable()) throw new Error("Leather not available"); + const args = functionArgs.map((cv) => cvToHex(cv)); + await window.LeatherProvider!.request("stx_callContract", { + contract, + functionName, + functionArgs: args, + network: "mainnet", + }); +} + +export async function leatherTransferStx( + recipient: string, + amount: string, + memo?: string +): Promise { + if (!isLeatherAvailable()) throw new Error("Leather not available"); + await window.LeatherProvider!.request("stx_transferStx", { + recipient, + amount, + network: "mainnet", + ...(memo && { memo }), + }); +} From e0f79afdbd8a8baf178792723bd43f1a4e3b9987 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 17 Feb 2026 10:03:59 +0100 Subject: [PATCH 02/42] chore: update @stacks/connect to 7.10.2 --- frontend/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4fd57a..7c9ba8b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -254,9 +254,9 @@ "license": "MIT" }, "node_modules/@stacks/connect": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@stacks/connect/-/connect-7.10.1.tgz", - "integrity": "sha512-8TO5hU3W/hF57zBgKh9e9/K+9Nrj7ALQKHNH/ZgXVukDjCd6PkZVyZk9j1G8uIGW0v+gS6bK0nhxd/WGZaR8Bg==", + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@stacks/connect/-/connect-7.10.2.tgz", + "integrity": "sha512-fQcdayBgq9XZnX4rqQxa//Gx9c0ycrmrZT9dZ01uHDlIr/ZxwU18d5A3hyYv4F7LQYQQkFr9htpVTlH0RSqWUw==", "license": "MIT", "dependencies": { "@stacks/auth": "^7.0.0", From 4999ccaf988766c3bd24288ebef1ee0e7dfe14f7 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 17 Feb 2026 10:05:07 +0100 Subject: [PATCH 03/42] feat: add loading state to Connect Wallet button --- .../components/ConnectWallet/ConnectWallet.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/components/ConnectWallet/ConnectWallet.tsx b/frontend/components/ConnectWallet/ConnectWallet.tsx index 4b64896..473b707 100644 --- a/frontend/components/ConnectWallet/ConnectWallet.tsx +++ b/frontend/components/ConnectWallet/ConnectWallet.tsx @@ -1,10 +1,21 @@ "use client"; +import { useState } from "react"; import { useStacks } from "@/context/StacksContext"; import styles from "./ConnectWallet.module.css"; export function ConnectWallet() { const { address, handleConnect, handleDisconnect } = useStacks(); + const [connecting, setConnecting] = useState(false); + + const onConnect = async () => { + setConnecting(true); + try { + await handleConnect(); + } finally { + setConnecting(false); + } + }; return (
@@ -17,8 +28,8 @@ export function ConnectWallet() { ) : ( <> - From ef0ed071b369675c9272a3f63def6ca85aaaf362 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 17 Feb 2026 10:05:13 +0100 Subject: [PATCH 04/42] feat: add shortenAddress and isValidPrincipal utils --- frontend/lib/utils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 frontend/lib/utils.ts diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts new file mode 100644 index 0000000..7c46fb6 --- /dev/null +++ b/frontend/lib/utils.ts @@ -0,0 +1,12 @@ +/** + * Common utilities for S-pay frontend. + */ + +export function shortenAddress(addr: string, start = 6, end = 4): string { + if (!addr || addr.length <= start + end) return addr; + return `${addr.slice(0, start)}…${addr.slice(-end)}`; +} + +export function isValidPrincipal(addr: string): boolean { + return /^S[PM2][0-9A-Z]{38}$/.test(addr); +} From b2d5b981118157f16fe34709159183adf9fc77c9 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 17 Feb 2026 10:05:16 +0100 Subject: [PATCH 05/42] feat: add useCopyAddress hook --- frontend/hooks/useCopyAddress.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 frontend/hooks/useCopyAddress.ts diff --git a/frontend/hooks/useCopyAddress.ts b/frontend/hooks/useCopyAddress.ts new file mode 100644 index 0000000..f6f8afb --- /dev/null +++ b/frontend/hooks/useCopyAddress.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useState, useCallback } from "react"; + +export function useCopyAddress() { + const [copied, setCopied] = useState(false); + + const copy = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + return true; + } catch { + return false; + } + }, []); + + return { copy, copied }; +} From ee97382a4a28e19a69533d9fb53f2c7b07b719a9 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 17 Feb 2026 10:05:22 +0100 Subject: [PATCH 06/42] feat: add copy address on click in Navbar --- frontend/components/Navbar/Navbar.module.css | 3 +++ frontend/components/Navbar/Navbar.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/components/Navbar/Navbar.module.css b/frontend/components/Navbar/Navbar.module.css index f2aa914..aba775f 100644 --- a/frontend/components/Navbar/Navbar.module.css +++ b/frontend/components/Navbar/Navbar.module.css @@ -62,6 +62,9 @@ background: rgba(255, 255, 255, 0.05); padding: 0.4rem 0.8rem; border-radius: 8px; + border: none; + cursor: pointer; + transition: var(--transition-smooth); } .connectBtn { diff --git a/frontend/components/Navbar/Navbar.tsx b/frontend/components/Navbar/Navbar.tsx index 76064a6..9d7f283 100644 --- a/frontend/components/Navbar/Navbar.tsx +++ b/frontend/components/Navbar/Navbar.tsx @@ -2,10 +2,13 @@ import Link from "next/link"; import { useStacks } from "@/context/StacksContext"; +import { useCopyAddress } from "@/hooks/useCopyAddress"; +import { shortenAddress } from "@/lib/utils"; import styles from "./Navbar.module.css"; export default function Navbar() { const { address: addr, handleConnect, handleDisconnect } = useStacks(); + const { copy, copied } = useCopyAddress(); return (