|
3 | 3 | disconnect, |
4 | 4 | isConnected, |
5 | 5 | request, |
6 | | -} from "https://esm.sh/@stacks/connect?bundle&target=es2020"; |
| 6 | +} from "https://esm.sh/@stacks/connect@8.2.5?bundle&target=es2020"; |
7 | 7 | import { createNetwork } from "https://esm.sh/@stacks/network@7.2.0?bundle&target=es2020"; |
8 | 8 | import { |
9 | 9 | Cl, |
@@ -511,6 +511,151 @@ function extractTxid(response) { |
511 | 511 | return null; |
512 | 512 | } |
513 | 513 |
|
| 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 | + |
514 | 659 | async function ensureWallet({ interactive }) { |
515 | 660 | if (state.connectedAddress) { |
516 | 661 | return state.connectedAddress; |
@@ -993,15 +1138,11 @@ async function handleSignTransfer() { |
993 | 1138 | try { |
994 | 1139 | await ensureWallet({ interactive: true }); |
995 | 1140 | 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 | + ); |
1005 | 1146 | state.lastSignature = signature; |
1006 | 1147 | elements.mySignature.value = signature; |
1007 | 1148 |
|
|
0 commit comments