From c03c5279342030d68eeae39f37b26d90b3d5dd9a Mon Sep 17 00:00:00 2001 From: Warm Idris Date: Fri, 27 Mar 2026 01:25:27 +0000 Subject: [PATCH 1/2] Add Leather wallet compatibility for SIP-018 signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin @stacks/connect to v8.2.5 for reproducible builds - Add VRS→RSV signature normalization (Leather returns VRS, Clarity expects RSV) - Add 3-tier signing fallback: @stacks/connect → direct provider → wallet-JSON format - Add cvToWalletJson() for older Leather versions that reject raw ClarityValue objects - Add wallet cancellation detection to avoid retrying after user explicitly cancels - Applied to both docs/app.js (docs UI) and server/ui/main.src.js (node console UI) Co-Authored-By: Claude Opus 4.6 --- docs/app.js | 161 +++++++++++++++++++++++++++++++++++++++--- server/ui/main.src.js | 134 ++++++++++++++++++++++++++++++++--- 2 files changed, 276 insertions(+), 19 deletions(-) diff --git a/docs/app.js b/docs/app.js index 2164a72..3f1e50f 100644 --- a/docs/app.js +++ b/docs/app.js @@ -3,7 +3,7 @@ import { disconnect, isConnected, request, -} from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +} from "https://esm.sh/@stacks/connect@8.2.5?bundle&target=es2020"; import { createNetwork } from "https://esm.sh/@stacks/network@7.2.0?bundle&target=es2020"; import { Cl, @@ -511,6 +511,151 @@ function extractTxid(response) { return null; } +// ── Leather wallet compatibility ──────────────────────────────────────────── + +/** + * Normalize SIP-018 signatures from VRS (Leather default) to RSV (Clarity expected). + * Leather and some other wallets return the recovery byte first (VRS format), + * but Clarity contracts and StackFlow verification expect RSV format. + */ +function normalizeSip018SignatureForClarity(signature) { + const raw = (signature ?? "").replace(/^0x/, "").toLowerCase(); + if (raw.length !== 130) return signature; + const firstByte = parseInt(raw.slice(0, 2), 16); + const lastByte = parseInt(raw.slice(128, 130), 16); + const isRecoveryId = (v) => v === 0 || v === 1 || v === 27 || v === 28; + if (isRecoveryId(firstByte) && !isRecoveryId(lastByte)) { + return `0x${raw.slice(2)}${raw.slice(0, 2)}`; + } + return signature; +} + +/** + * Convert a ClarityValue to wallet-JSON format for older Leather versions + * that don't accept raw ClarityValue objects in stx_signStructuredMessage. + * Uses string-based type identifiers from @stacks/transactions v7. + */ +function cvToWalletJson(cv) { + if (!cv || typeof cv !== "object") return cv; + switch (cv.type) { + case "int": return { type: "int", value: String(cv.value) }; + case "uint": return { type: "uint", value: String(cv.value) }; + case "address": + case "contract": return { type: "principal", value: cv.value }; + case "ascii": return { type: "string-ascii", value: cv.value }; + case "utf8": return { type: "string-utf8", value: cv.value }; + case "none": return { type: "none" }; + case "some": return { type: "some", value: cvToWalletJson(cv.value) }; + case "ok": return { type: "ok", value: cvToWalletJson(cv.value) }; + case "err": return { type: "err", value: cvToWalletJson(cv.value) }; + case "buffer": return { type: "buffer", data: cv.value }; + case "true": return { type: "bool", value: true }; + case "false": return { type: "bool", value: false }; + case "tuple": { + const data = {}; + for (const [k, v] of Object.entries(cv.value || {})) { + data[k] = cvToWalletJson(v); + } + return { type: "tuple", data }; + } + case "list": { + const list = (cv.value || []).map(cvToWalletJson); + return { type: "list", list }; + } + default: return cv; + } +} + +function getLeatherProvider() { + return window.LeatherProvider ?? null; +} + +function isWalletCancellationError(error) { + const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase(); + return msg.includes("cancelled") || msg.includes("canceled") + || msg.includes("user rejected") || msg.includes("user denied") + || msg.includes("denied by user") || msg.includes("request aborted") + || msg.includes("aborterror") || msg.includes("4001"); +} + +function normalizeWalletPromptError(error, action) { + if (isWalletCancellationError(error)) { + if (action === "connect") return new Error("Wallet connection cancelled"); + if (action === "transaction") return new Error("Transaction cancelled"); + return new Error("Signature cancelled"); + } + return error instanceof Error ? error : new Error(String(error ?? "Unknown wallet error")); +} + +/** + * Sign a SIP-018 structured message with 3-tier fallback: + * 1. @stacks/connect request() — standard path, works with modern wallets + * 2. Direct provider.request() with ClarityValue objects + * 3. Direct provider.request() with wallet-JSON format (older Leather) + * + * Returns the normalized RSV signature. + */ +async function signStructuredMessageWithFallback(domain, message, network) { + let lastError = null; + + // Path 1: @stacks/connect request() + try { + const response = await request("stx_signStructuredMessage", { + network, + domain, + message, + }); + const sig = extractSignature(response); + if (sig) return normalizeSip018SignatureForClarity(sig); + } catch (err) { + lastError = normalizeWalletPromptError(err, "signature"); + // If the user explicitly cancelled, don't try fallbacks + if (isWalletCancellationError(err)) throw lastError; + } + + // Path 2: Direct provider with ClarityValue objects + const provider = getLeatherProvider(); + if (provider) { + try { + const response = await provider.request("stx_signStructuredMessage", { + network, + message, + domain, + }); + const sig = extractSignature(response); + if (sig) return normalizeSip018SignatureForClarity(sig); + } catch (err) { + lastError = normalizeWalletPromptError(err, "signature"); + if (isWalletCancellationError(err)) throw lastError; + } + + // Path 3: Wallet-JSON format for older providers + try { + const response = await provider.request("stx_signStructuredMessage", { + network, + message: cvToWalletJson(message), + domain: cvToWalletJson(domain), + }); + const sig = extractSignature(response); + if (sig) return normalizeSip018SignatureForClarity(sig); + } catch (err) { + lastError = normalizeWalletPromptError(err, "signature"); + if (isWalletCancellationError(err)) throw lastError; + } + } + + if (lastError) { + const msg = lastError.message ?? ""; + if (msg.includes("not supported") || msg.includes("structured")) { + throw new Error("Your wallet doesn't support structured data signing (SIP-018). Try Leather v6+."); + } + throw lastError; + } + throw new Error("Wallet did not return a signature"); +} + +// ──────────────────────────────────────────────────────────────────────────── + async function ensureWallet({ interactive }) { if (state.connectedAddress) { return state.connectedAddress; @@ -993,15 +1138,11 @@ async function handleSignTransfer() { try { await ensureWallet({ interactive: true }); const context = await buildTransferContext(); - const response = await request("stx_signStructuredMessage", { - network: readNetwork(), - domain: context.domain, - message: context.message, - }); - const signature = extractSignature(response); - if (!signature) { - throw new Error("Wallet did not return a signature"); - } + const signature = await signStructuredMessageWithFallback( + context.domain, + context.message, + readNetwork(), + ); state.lastSignature = signature; elements.mySignature.value = signature; diff --git a/server/ui/main.src.js b/server/ui/main.src.js index 36689a6..204d801 100644 --- a/server/ui/main.src.js +++ b/server/ui/main.src.js @@ -1,4 +1,4 @@ -import { connect, disconnect, isConnected, request } from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +import { connect, disconnect, isConnected, request } from "https://esm.sh/@stacks/connect@8.2.5?bundle&target=es2020"; import { Cl, Pc, @@ -929,6 +929,126 @@ function extractTxid(response) { return null; } +// ── Leather wallet compatibility ──────────────────────────────────────────── + +function normalizeSip018SignatureForClarity(signature) { + const raw = (signature ?? "").replace(/^0x/, "").toLowerCase(); + if (raw.length !== 130) return signature; + const firstByte = parseInt(raw.slice(0, 2), 16); + const lastByte = parseInt(raw.slice(128, 130), 16); + const isRecoveryId = (v) => v === 0 || v === 1 || v === 27 || v === 28; + if (isRecoveryId(firstByte) && !isRecoveryId(lastByte)) { + return `0x${raw.slice(2)}${raw.slice(0, 2)}`; + } + return signature; +} + +function cvToWalletJson(cv) { + if (!cv || typeof cv !== "object") return cv; + switch (cv.type) { + case "int": return { type: "int", value: String(cv.value) }; + case "uint": return { type: "uint", value: String(cv.value) }; + case "address": + case "contract": return { type: "principal", value: cv.value }; + case "ascii": return { type: "string-ascii", value: cv.value }; + case "utf8": return { type: "string-utf8", value: cv.value }; + case "none": return { type: "none" }; + case "some": return { type: "some", value: cvToWalletJson(cv.value) }; + case "ok": return { type: "ok", value: cvToWalletJson(cv.value) }; + case "err": return { type: "err", value: cvToWalletJson(cv.value) }; + case "buffer": return { type: "buffer", data: cv.value }; + case "true": return { type: "bool", value: true }; + case "false": return { type: "bool", value: false }; + case "tuple": { + const data = {}; + for (const [k, v] of Object.entries(cv.value || {})) { + data[k] = cvToWalletJson(v); + } + return { type: "tuple", data }; + } + case "list": { + const list = (cv.value || []).map(cvToWalletJson); + return { type: "list", list }; + } + default: return cv; + } +} + +function getLeatherProvider() { + return window.LeatherProvider ?? null; +} + +function isWalletCancellationError(error) { + const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase(); + return msg.includes("cancelled") || msg.includes("canceled") + || msg.includes("user rejected") || msg.includes("user denied") + || msg.includes("denied by user") || msg.includes("request aborted") + || msg.includes("aborterror") || msg.includes("4001"); +} + +function normalizeWalletPromptError(error, action) { + if (isWalletCancellationError(error)) { + if (action === "connect") return new Error("Wallet connection cancelled"); + if (action === "transaction") return new Error("Transaction cancelled"); + return new Error("Signature cancelled"); + } + return error instanceof Error ? error : new Error(String(error ?? "Unknown wallet error")); +} + +async function signStructuredMessageWithFallback(domain, message) { + let lastError = null; + + try { + const response = await request("stx_signStructuredMessage", { + domain, + message, + }); + const sig = extractSignature(response); + if (sig) return normalizeSip018SignatureForClarity(sig); + } catch (err) { + lastError = normalizeWalletPromptError(err, "signature"); + if (isWalletCancellationError(err)) throw lastError; + } + + const provider = getLeatherProvider(); + if (provider) { + try { + const response = await provider.request("stx_signStructuredMessage", { + message, + domain, + }); + const sig = extractSignature(response); + if (sig) return normalizeSip018SignatureForClarity(sig); + } catch (err) { + lastError = normalizeWalletPromptError(err, "signature"); + if (isWalletCancellationError(err)) throw lastError; + } + + try { + const response = await provider.request("stx_signStructuredMessage", { + message: cvToWalletJson(message), + domain: cvToWalletJson(domain), + }); + const sig = extractSignature(response); + if (sig) return normalizeSip018SignatureForClarity(sig); + } catch (err) { + lastError = normalizeWalletPromptError(err, "signature"); + if (isWalletCancellationError(err)) throw lastError; + } + } + + if (lastError) { + const msg = lastError.message ?? ""; + if (msg.includes("not supported") || msg.includes("structured")) { + throw new Error("Your wallet doesn't support structured data signing (SIP-018). Try Leather v6+."); + } + throw lastError; + } + throw new Error("Wallet did not return a signature"); +} + +// ──────────────────────────────────────────────────────────────────────────── + function buildStackflowNodePayload() { const parsed = parseSignerInputs(); const contractId = parseContractId(); @@ -1378,14 +1498,10 @@ async function signStructuredState() { } const state = await buildStructuredState(); - const response = await request("stx_signStructuredMessage", { - domain: state.domain, - message: state.message, - }); - const signature = extractSignature(response); - if (!signature) { - throw new Error("Wallet did not return a signature"); - } + const signature = await signStructuredMessageWithFallback( + state.domain, + state.message, + ); getInput(ids.sigMySignature).value = normalizeHex( signature, From 704b448a12795d839cd5db88fb9d5198a233df3b Mon Sep 17 00:00:00 2001 From: Warm Idris Date: Fri, 27 Mar 2026 14:28:52 +0000 Subject: [PATCH 2/2] Slim down to version pin + wallet cancellation detection only Remove over-engineered Leather compatibility layer (signature normalization, ClarityValue-to-JSON conversion, 3-tier fallback signing). Keep only the @stacks/connect@8.2.5 version pin and wallet cancellation error handling around the original direct request() calls. Co-Authored-By: Claude Opus 4.6 --- docs/app.js | 147 +++++------------------------------------- server/ui/main.src.js | 120 ++++------------------------------ 2 files changed, 29 insertions(+), 238 deletions(-) diff --git a/docs/app.js b/docs/app.js index 3f1e50f..fde1216 100644 --- a/docs/app.js +++ b/docs/app.js @@ -511,64 +511,7 @@ function extractTxid(response) { return null; } -// ── Leather wallet compatibility ──────────────────────────────────────────── - -/** - * Normalize SIP-018 signatures from VRS (Leather default) to RSV (Clarity expected). - * Leather and some other wallets return the recovery byte first (VRS format), - * but Clarity contracts and StackFlow verification expect RSV format. - */ -function normalizeSip018SignatureForClarity(signature) { - const raw = (signature ?? "").replace(/^0x/, "").toLowerCase(); - if (raw.length !== 130) return signature; - const firstByte = parseInt(raw.slice(0, 2), 16); - const lastByte = parseInt(raw.slice(128, 130), 16); - const isRecoveryId = (v) => v === 0 || v === 1 || v === 27 || v === 28; - if (isRecoveryId(firstByte) && !isRecoveryId(lastByte)) { - return `0x${raw.slice(2)}${raw.slice(0, 2)}`; - } - return signature; -} - -/** - * Convert a ClarityValue to wallet-JSON format for older Leather versions - * that don't accept raw ClarityValue objects in stx_signStructuredMessage. - * Uses string-based type identifiers from @stacks/transactions v7. - */ -function cvToWalletJson(cv) { - if (!cv || typeof cv !== "object") return cv; - switch (cv.type) { - case "int": return { type: "int", value: String(cv.value) }; - case "uint": return { type: "uint", value: String(cv.value) }; - case "address": - case "contract": return { type: "principal", value: cv.value }; - case "ascii": return { type: "string-ascii", value: cv.value }; - case "utf8": return { type: "string-utf8", value: cv.value }; - case "none": return { type: "none" }; - case "some": return { type: "some", value: cvToWalletJson(cv.value) }; - case "ok": return { type: "ok", value: cvToWalletJson(cv.value) }; - case "err": return { type: "err", value: cvToWalletJson(cv.value) }; - case "buffer": return { type: "buffer", data: cv.value }; - case "true": return { type: "bool", value: true }; - case "false": return { type: "bool", value: false }; - case "tuple": { - const data = {}; - for (const [k, v] of Object.entries(cv.value || {})) { - data[k] = cvToWalletJson(v); - } - return { type: "tuple", data }; - } - case "list": { - const list = (cv.value || []).map(cvToWalletJson); - return { type: "list", list }; - } - default: return cv; - } -} - -function getLeatherProvider() { - return window.LeatherProvider ?? null; -} +// ── Wallet error handling ─────────────────────────────────────────────────── function isWalletCancellationError(error) { const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase(); @@ -587,75 +530,6 @@ function normalizeWalletPromptError(error, action) { return error instanceof Error ? error : new Error(String(error ?? "Unknown wallet error")); } -/** - * Sign a SIP-018 structured message with 3-tier fallback: - * 1. @stacks/connect request() — standard path, works with modern wallets - * 2. Direct provider.request() with ClarityValue objects - * 3. Direct provider.request() with wallet-JSON format (older Leather) - * - * Returns the normalized RSV signature. - */ -async function signStructuredMessageWithFallback(domain, message, network) { - let lastError = null; - - // Path 1: @stacks/connect request() - try { - const response = await request("stx_signStructuredMessage", { - network, - domain, - message, - }); - const sig = extractSignature(response); - if (sig) return normalizeSip018SignatureForClarity(sig); - } catch (err) { - lastError = normalizeWalletPromptError(err, "signature"); - // If the user explicitly cancelled, don't try fallbacks - if (isWalletCancellationError(err)) throw lastError; - } - - // Path 2: Direct provider with ClarityValue objects - const provider = getLeatherProvider(); - if (provider) { - try { - const response = await provider.request("stx_signStructuredMessage", { - network, - message, - domain, - }); - const sig = extractSignature(response); - if (sig) return normalizeSip018SignatureForClarity(sig); - } catch (err) { - lastError = normalizeWalletPromptError(err, "signature"); - if (isWalletCancellationError(err)) throw lastError; - } - - // Path 3: Wallet-JSON format for older providers - try { - const response = await provider.request("stx_signStructuredMessage", { - network, - message: cvToWalletJson(message), - domain: cvToWalletJson(domain), - }); - const sig = extractSignature(response); - if (sig) return normalizeSip018SignatureForClarity(sig); - } catch (err) { - lastError = normalizeWalletPromptError(err, "signature"); - if (isWalletCancellationError(err)) throw lastError; - } - } - - if (lastError) { - const msg = lastError.message ?? ""; - if (msg.includes("not supported") || msg.includes("structured")) { - throw new Error("Your wallet doesn't support structured data signing (SIP-018). Try Leather v6+."); - } - throw lastError; - } - throw new Error("Wallet did not return a signature"); -} - -// ──────────────────────────────────────────────────────────────────────────── - async function ensureWallet({ interactive }) { if (state.connectedAddress) { return state.connectedAddress; @@ -1138,11 +1012,20 @@ async function handleSignTransfer() { try { await ensureWallet({ interactive: true }); const context = await buildTransferContext(); - const signature = await signStructuredMessageWithFallback( - context.domain, - context.message, - readNetwork(), - ); + let response; + try { + response = await request("stx_signStructuredMessage", { + network: readNetwork(), + domain: context.domain, + message: context.message, + }); + } catch (err) { + throw normalizeWalletPromptError(err, "signature"); + } + const signature = extractSignature(response); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } state.lastSignature = signature; elements.mySignature.value = signature; diff --git a/server/ui/main.src.js b/server/ui/main.src.js index 204d801..9476b18 100644 --- a/server/ui/main.src.js +++ b/server/ui/main.src.js @@ -929,54 +929,7 @@ function extractTxid(response) { return null; } -// ── Leather wallet compatibility ──────────────────────────────────────────── - -function normalizeSip018SignatureForClarity(signature) { - const raw = (signature ?? "").replace(/^0x/, "").toLowerCase(); - if (raw.length !== 130) return signature; - const firstByte = parseInt(raw.slice(0, 2), 16); - const lastByte = parseInt(raw.slice(128, 130), 16); - const isRecoveryId = (v) => v === 0 || v === 1 || v === 27 || v === 28; - if (isRecoveryId(firstByte) && !isRecoveryId(lastByte)) { - return `0x${raw.slice(2)}${raw.slice(0, 2)}`; - } - return signature; -} - -function cvToWalletJson(cv) { - if (!cv || typeof cv !== "object") return cv; - switch (cv.type) { - case "int": return { type: "int", value: String(cv.value) }; - case "uint": return { type: "uint", value: String(cv.value) }; - case "address": - case "contract": return { type: "principal", value: cv.value }; - case "ascii": return { type: "string-ascii", value: cv.value }; - case "utf8": return { type: "string-utf8", value: cv.value }; - case "none": return { type: "none" }; - case "some": return { type: "some", value: cvToWalletJson(cv.value) }; - case "ok": return { type: "ok", value: cvToWalletJson(cv.value) }; - case "err": return { type: "err", value: cvToWalletJson(cv.value) }; - case "buffer": return { type: "buffer", data: cv.value }; - case "true": return { type: "bool", value: true }; - case "false": return { type: "bool", value: false }; - case "tuple": { - const data = {}; - for (const [k, v] of Object.entries(cv.value || {})) { - data[k] = cvToWalletJson(v); - } - return { type: "tuple", data }; - } - case "list": { - const list = (cv.value || []).map(cvToWalletJson); - return { type: "list", list }; - } - default: return cv; - } -} - -function getLeatherProvider() { - return window.LeatherProvider ?? null; -} +// ── Wallet error handling ─────────────────────────────────────────────────── function isWalletCancellationError(error) { const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase(); @@ -995,60 +948,6 @@ function normalizeWalletPromptError(error, action) { return error instanceof Error ? error : new Error(String(error ?? "Unknown wallet error")); } -async function signStructuredMessageWithFallback(domain, message) { - let lastError = null; - - try { - const response = await request("stx_signStructuredMessage", { - domain, - message, - }); - const sig = extractSignature(response); - if (sig) return normalizeSip018SignatureForClarity(sig); - } catch (err) { - lastError = normalizeWalletPromptError(err, "signature"); - if (isWalletCancellationError(err)) throw lastError; - } - - const provider = getLeatherProvider(); - if (provider) { - try { - const response = await provider.request("stx_signStructuredMessage", { - message, - domain, - }); - const sig = extractSignature(response); - if (sig) return normalizeSip018SignatureForClarity(sig); - } catch (err) { - lastError = normalizeWalletPromptError(err, "signature"); - if (isWalletCancellationError(err)) throw lastError; - } - - try { - const response = await provider.request("stx_signStructuredMessage", { - message: cvToWalletJson(message), - domain: cvToWalletJson(domain), - }); - const sig = extractSignature(response); - if (sig) return normalizeSip018SignatureForClarity(sig); - } catch (err) { - lastError = normalizeWalletPromptError(err, "signature"); - if (isWalletCancellationError(err)) throw lastError; - } - } - - if (lastError) { - const msg = lastError.message ?? ""; - if (msg.includes("not supported") || msg.includes("structured")) { - throw new Error("Your wallet doesn't support structured data signing (SIP-018). Try Leather v6+."); - } - throw lastError; - } - throw new Error("Wallet did not return a signature"); -} - -// ──────────────────────────────────────────────────────────────────────────── - function buildStackflowNodePayload() { const parsed = parseSignerInputs(); const contractId = parseContractId(); @@ -1498,10 +1397,19 @@ async function signStructuredState() { } const state = await buildStructuredState(); - const signature = await signStructuredMessageWithFallback( - state.domain, - state.message, - ); + let response; + try { + response = await request("stx_signStructuredMessage", { + domain: state.domain, + message: state.message, + }); + } catch (err) { + throw normalizeWalletPromptError(err, "signature"); + } + const signature = extractSignature(response); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } getInput(ids.sigMySignature).value = normalizeHex( signature,