Skip to content

Commit c03c527

Browse files
warmidrisclaude
andcommitted
Add Leather wallet compatibility for SIP-018 signing
- 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 <noreply@anthropic.com>
1 parent f963ba0 commit c03c527

2 files changed

Lines changed: 276 additions & 19 deletions

File tree

docs/app.js

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
disconnect,
44
isConnected,
55
request,
6-
} from "https://esm.sh/@stacks/connect?bundle&target=es2020";
6+
} from "https://esm.sh/@stacks/connect@8.2.5?bundle&target=es2020";
77
import { createNetwork } from "https://esm.sh/@stacks/network@7.2.0?bundle&target=es2020";
88
import {
99
Cl,
@@ -511,6 +511,151 @@ function extractTxid(response) {
511511
return null;
512512
}
513513

514+
// ── Leather wallet compatibility ────────────────────────────────────────────
515+
516+
/**
517+
* Normalize SIP-018 signatures from VRS (Leather default) to RSV (Clarity expected).
518+
* Leather and some other wallets return the recovery byte first (VRS format),
519+
* but Clarity contracts and StackFlow verification expect RSV format.
520+
*/
521+
function normalizeSip018SignatureForClarity(signature) {
522+
const raw = (signature ?? "").replace(/^0x/, "").toLowerCase();
523+
if (raw.length !== 130) return signature;
524+
const firstByte = parseInt(raw.slice(0, 2), 16);
525+
const lastByte = parseInt(raw.slice(128, 130), 16);
526+
const isRecoveryId = (v) => v === 0 || v === 1 || v === 27 || v === 28;
527+
if (isRecoveryId(firstByte) && !isRecoveryId(lastByte)) {
528+
return `0x${raw.slice(2)}${raw.slice(0, 2)}`;
529+
}
530+
return signature;
531+
}
532+
533+
/**
534+
* Convert a ClarityValue to wallet-JSON format for older Leather versions
535+
* that don't accept raw ClarityValue objects in stx_signStructuredMessage.
536+
* Uses string-based type identifiers from @stacks/transactions v7.
537+
*/
538+
function cvToWalletJson(cv) {
539+
if (!cv || typeof cv !== "object") return cv;
540+
switch (cv.type) {
541+
case "int": return { type: "int", value: String(cv.value) };
542+
case "uint": return { type: "uint", value: String(cv.value) };
543+
case "address":
544+
case "contract": return { type: "principal", value: cv.value };
545+
case "ascii": return { type: "string-ascii", value: cv.value };
546+
case "utf8": return { type: "string-utf8", value: cv.value };
547+
case "none": return { type: "none" };
548+
case "some": return { type: "some", value: cvToWalletJson(cv.value) };
549+
case "ok": return { type: "ok", value: cvToWalletJson(cv.value) };
550+
case "err": return { type: "err", value: cvToWalletJson(cv.value) };
551+
case "buffer": return { type: "buffer", data: cv.value };
552+
case "true": return { type: "bool", value: true };
553+
case "false": return { type: "bool", value: false };
554+
case "tuple": {
555+
const data = {};
556+
for (const [k, v] of Object.entries(cv.value || {})) {
557+
data[k] = cvToWalletJson(v);
558+
}
559+
return { type: "tuple", data };
560+
}
561+
case "list": {
562+
const list = (cv.value || []).map(cvToWalletJson);
563+
return { type: "list", list };
564+
}
565+
default: return cv;
566+
}
567+
}
568+
569+
function getLeatherProvider() {
570+
return window.LeatherProvider ?? null;
571+
}
572+
573+
function isWalletCancellationError(error) {
574+
const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase();
575+
return msg.includes("cancelled") || msg.includes("canceled")
576+
|| msg.includes("user rejected") || msg.includes("user denied")
577+
|| msg.includes("denied by user") || msg.includes("request aborted")
578+
|| msg.includes("aborterror") || msg.includes("4001");
579+
}
580+
581+
function normalizeWalletPromptError(error, action) {
582+
if (isWalletCancellationError(error)) {
583+
if (action === "connect") return new Error("Wallet connection cancelled");
584+
if (action === "transaction") return new Error("Transaction cancelled");
585+
return new Error("Signature cancelled");
586+
}
587+
return error instanceof Error ? error : new Error(String(error ?? "Unknown wallet error"));
588+
}
589+
590+
/**
591+
* Sign a SIP-018 structured message with 3-tier fallback:
592+
* 1. @stacks/connect request() — standard path, works with modern wallets
593+
* 2. Direct provider.request() with ClarityValue objects
594+
* 3. Direct provider.request() with wallet-JSON format (older Leather)
595+
*
596+
* Returns the normalized RSV signature.
597+
*/
598+
async function signStructuredMessageWithFallback(domain, message, network) {
599+
let lastError = null;
600+
601+
// Path 1: @stacks/connect request()
602+
try {
603+
const response = await request("stx_signStructuredMessage", {
604+
network,
605+
domain,
606+
message,
607+
});
608+
const sig = extractSignature(response);
609+
if (sig) return normalizeSip018SignatureForClarity(sig);
610+
} catch (err) {
611+
lastError = normalizeWalletPromptError(err, "signature");
612+
// If the user explicitly cancelled, don't try fallbacks
613+
if (isWalletCancellationError(err)) throw lastError;
614+
}
615+
616+
// Path 2: Direct provider with ClarityValue objects
617+
const provider = getLeatherProvider();
618+
if (provider) {
619+
try {
620+
const response = await provider.request("stx_signStructuredMessage", {
621+
network,
622+
message,
623+
domain,
624+
});
625+
const sig = extractSignature(response);
626+
if (sig) return normalizeSip018SignatureForClarity(sig);
627+
} catch (err) {
628+
lastError = normalizeWalletPromptError(err, "signature");
629+
if (isWalletCancellationError(err)) throw lastError;
630+
}
631+
632+
// Path 3: Wallet-JSON format for older providers
633+
try {
634+
const response = await provider.request("stx_signStructuredMessage", {
635+
network,
636+
message: cvToWalletJson(message),
637+
domain: cvToWalletJson(domain),
638+
});
639+
const sig = extractSignature(response);
640+
if (sig) return normalizeSip018SignatureForClarity(sig);
641+
} catch (err) {
642+
lastError = normalizeWalletPromptError(err, "signature");
643+
if (isWalletCancellationError(err)) throw lastError;
644+
}
645+
}
646+
647+
if (lastError) {
648+
const msg = lastError.message ?? "";
649+
if (msg.includes("not supported") || msg.includes("structured")) {
650+
throw new Error("Your wallet doesn't support structured data signing (SIP-018). Try Leather v6+.");
651+
}
652+
throw lastError;
653+
}
654+
throw new Error("Wallet did not return a signature");
655+
}
656+
657+
// ────────────────────────────────────────────────────────────────────────────
658+
514659
async function ensureWallet({ interactive }) {
515660
if (state.connectedAddress) {
516661
return state.connectedAddress;
@@ -993,15 +1138,11 @@ async function handleSignTransfer() {
9931138
try {
9941139
await ensureWallet({ interactive: true });
9951140
const context = await buildTransferContext();
996-
const response = await request("stx_signStructuredMessage", {
997-
network: readNetwork(),
998-
domain: context.domain,
999-
message: context.message,
1000-
});
1001-
const signature = extractSignature(response);
1002-
if (!signature) {
1003-
throw new Error("Wallet did not return a signature");
1004-
}
1141+
const signature = await signStructuredMessageWithFallback(
1142+
context.domain,
1143+
context.message,
1144+
readNetwork(),
1145+
);
10051146
state.lastSignature = signature;
10061147
elements.mySignature.value = signature;
10071148

server/ui/main.src.js

Lines changed: 125 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { connect, disconnect, isConnected, request } from "https://esm.sh/@stacks/connect?bundle&target=es2020";
1+
import { connect, disconnect, isConnected, request } from "https://esm.sh/@stacks/connect@8.2.5?bundle&target=es2020";
22
import {
33
Cl,
44
Pc,
@@ -929,6 +929,126 @@ function extractTxid(response) {
929929
return null;
930930
}
931931

932+
// ── Leather wallet compatibility ────────────────────────────────────────────
933+
934+
function normalizeSip018SignatureForClarity(signature) {
935+
const raw = (signature ?? "").replace(/^0x/, "").toLowerCase();
936+
if (raw.length !== 130) return signature;
937+
const firstByte = parseInt(raw.slice(0, 2), 16);
938+
const lastByte = parseInt(raw.slice(128, 130), 16);
939+
const isRecoveryId = (v) => v === 0 || v === 1 || v === 27 || v === 28;
940+
if (isRecoveryId(firstByte) && !isRecoveryId(lastByte)) {
941+
return `0x${raw.slice(2)}${raw.slice(0, 2)}`;
942+
}
943+
return signature;
944+
}
945+
946+
function cvToWalletJson(cv) {
947+
if (!cv || typeof cv !== "object") return cv;
948+
switch (cv.type) {
949+
case "int": return { type: "int", value: String(cv.value) };
950+
case "uint": return { type: "uint", value: String(cv.value) };
951+
case "address":
952+
case "contract": return { type: "principal", value: cv.value };
953+
case "ascii": return { type: "string-ascii", value: cv.value };
954+
case "utf8": return { type: "string-utf8", value: cv.value };
955+
case "none": return { type: "none" };
956+
case "some": return { type: "some", value: cvToWalletJson(cv.value) };
957+
case "ok": return { type: "ok", value: cvToWalletJson(cv.value) };
958+
case "err": return { type: "err", value: cvToWalletJson(cv.value) };
959+
case "buffer": return { type: "buffer", data: cv.value };
960+
case "true": return { type: "bool", value: true };
961+
case "false": return { type: "bool", value: false };
962+
case "tuple": {
963+
const data = {};
964+
for (const [k, v] of Object.entries(cv.value || {})) {
965+
data[k] = cvToWalletJson(v);
966+
}
967+
return { type: "tuple", data };
968+
}
969+
case "list": {
970+
const list = (cv.value || []).map(cvToWalletJson);
971+
return { type: "list", list };
972+
}
973+
default: return cv;
974+
}
975+
}
976+
977+
function getLeatherProvider() {
978+
return window.LeatherProvider ?? null;
979+
}
980+
981+
function isWalletCancellationError(error) {
982+
const msg = (error instanceof Error ? error.message : String(error ?? "")).toLowerCase();
983+
return msg.includes("cancelled") || msg.includes("canceled")
984+
|| msg.includes("user rejected") || msg.includes("user denied")
985+
|| msg.includes("denied by user") || msg.includes("request aborted")
986+
|| msg.includes("aborterror") || msg.includes("4001");
987+
}
988+
989+
function normalizeWalletPromptError(error, action) {
990+
if (isWalletCancellationError(error)) {
991+
if (action === "connect") return new Error("Wallet connection cancelled");
992+
if (action === "transaction") return new Error("Transaction cancelled");
993+
return new Error("Signature cancelled");
994+
}
995+
return error instanceof Error ? error : new Error(String(error ?? "Unknown wallet error"));
996+
}
997+
998+
async function signStructuredMessageWithFallback(domain, message) {
999+
let lastError = null;
1000+
1001+
try {
1002+
const response = await request("stx_signStructuredMessage", {
1003+
domain,
1004+
message,
1005+
});
1006+
const sig = extractSignature(response);
1007+
if (sig) return normalizeSip018SignatureForClarity(sig);
1008+
} catch (err) {
1009+
lastError = normalizeWalletPromptError(err, "signature");
1010+
if (isWalletCancellationError(err)) throw lastError;
1011+
}
1012+
1013+
const provider = getLeatherProvider();
1014+
if (provider) {
1015+
try {
1016+
const response = await provider.request("stx_signStructuredMessage", {
1017+
message,
1018+
domain,
1019+
});
1020+
const sig = extractSignature(response);
1021+
if (sig) return normalizeSip018SignatureForClarity(sig);
1022+
} catch (err) {
1023+
lastError = normalizeWalletPromptError(err, "signature");
1024+
if (isWalletCancellationError(err)) throw lastError;
1025+
}
1026+
1027+
try {
1028+
const response = await provider.request("stx_signStructuredMessage", {
1029+
message: cvToWalletJson(message),
1030+
domain: cvToWalletJson(domain),
1031+
});
1032+
const sig = extractSignature(response);
1033+
if (sig) return normalizeSip018SignatureForClarity(sig);
1034+
} catch (err) {
1035+
lastError = normalizeWalletPromptError(err, "signature");
1036+
if (isWalletCancellationError(err)) throw lastError;
1037+
}
1038+
}
1039+
1040+
if (lastError) {
1041+
const msg = lastError.message ?? "";
1042+
if (msg.includes("not supported") || msg.includes("structured")) {
1043+
throw new Error("Your wallet doesn't support structured data signing (SIP-018). Try Leather v6+.");
1044+
}
1045+
throw lastError;
1046+
}
1047+
throw new Error("Wallet did not return a signature");
1048+
}
1049+
1050+
// ────────────────────────────────────────────────────────────────────────────
1051+
9321052
function buildStackflowNodePayload() {
9331053
const parsed = parseSignerInputs();
9341054
const contractId = parseContractId();
@@ -1378,14 +1498,10 @@ async function signStructuredState() {
13781498
}
13791499

13801500
const state = await buildStructuredState();
1381-
const response = await request("stx_signStructuredMessage", {
1382-
domain: state.domain,
1383-
message: state.message,
1384-
});
1385-
const signature = extractSignature(response);
1386-
if (!signature) {
1387-
throw new Error("Wallet did not return a signature");
1388-
}
1501+
const signature = await signStructuredMessageWithFallback(
1502+
state.domain,
1503+
state.message,
1504+
);
13891505

13901506
getInput(ids.sigMySignature).value = normalizeHex(
13911507
signature,

0 commit comments

Comments
 (0)