| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -10,7 +10,9 @@ import { | |||
| 10 | 10 | Pc, | |
| 11 | 11 | cvToJSON, | |
| 12 | 12 | fetchCallReadOnlyFunction, | |
| 13 | + getAddressFromPublicKey, | ||
| 13 | 14 | principalCV, | |
| 15 | + publicKeyFromSignatureRsv, | ||
| 14 | 16 | serializeCV, | |
| 15 | 17 | } from "https://esm.sh/@stacks/transactions@7.2.0?bundle&target=es2020"; | |
| 16 | 18 | ||
@@ -83,13 +85,33 @@ const elements = { | |||
| 83 | 85 | forPrincipal: document.getElementById("for-principal"), | |
| 84 | 86 | openAmount: document.getElementById("open-amount"), | |
| 85 | 87 | openNonce: document.getElementById("open-nonce"), | |
| 86 | - myBalance: document.getElementById("my-balance"), | ||
| 87 | - theirBalance: document.getElementById("their-balance"), | ||
| 88 | - transferNonce: document.getElementById("transfer-nonce"), | ||
| 89 | - transferAction: document.getElementById("transfer-action"), | ||
| 88 | + // Pipe State | ||
| 89 | + pipeNonce: document.getElementById("pipe-nonce"), | ||
| 90 | + pipeMyBalance: document.getElementById("pipe-my-balance"), | ||
| 91 | + pipeTheirBalance: document.getElementById("pipe-their-balance"), | ||
| 92 | + // Proposed Action | ||
| 93 | + actionType: document.getElementById("action-type"), | ||
| 94 | + actionAmount: document.getElementById("action-amount"), | ||
| 95 | + actionAmountRow: document.getElementById("action-amount-row"), | ||
| 96 | + resultNonce: document.getElementById("result-nonce"), | ||
| 97 | + resultActionCode: document.getElementById("result-action-code"), | ||
| 98 | + resultMyBalance: document.getElementById("result-my-balance"), | ||
| 99 | + resultTheirBalance: document.getElementById("result-their-balance"), | ||
| 100 | + // Shared transfer fields | ||
| 90 | 101 | transferActor: document.getElementById("transfer-actor"), | |
| 102 | + actorCustomRow: document.getElementById("actor-custom-row"), | ||
| 103 | + transferActorCustom: document.getElementById("transfer-actor-custom"), | ||
| 91 | 104 | transferSecret: document.getElementById("transfer-secret"), | |
| 92 | 105 | transferValidAfter: document.getElementById("transfer-valid-after"), | |
| 106 | + // Sign & Validate | ||
| 107 | + mySignature: document.getElementById("my-signature"), | ||
| 108 | + validateSignature: document.getElementById("validate-signature"), | ||
| 109 | + validateSigBtn: document.getElementById("validate-sig-btn"), | ||
| 110 | + useMySignatureBtn: document.getElementById("use-my-sig-btn"), | ||
| 111 | + validationResult: document.getElementById("validation-result"), | ||
| 112 | + validationIcon: document.getElementById("validation-icon"), | ||
| 113 | + validationText: document.getElementById("validation-text"), | ||
| 114 | + // Common | ||
| 93 | 115 | walletStatus: document.getElementById("wallet-status"), | |
| 94 | 116 | connectWallet: document.getElementById("connect-wallet"), | |
| 95 | 117 | disconnectWallet: document.getElementById("disconnect-wallet"), | |
@@ -512,8 +534,8 @@ async function ensureWallet({ interactive }) { | |||
| 512 | 534 | } | |
| 513 | 535 | state.connectedAddress = address; | |
| 514 | 536 | elements.forPrincipal.value = elements.forPrincipal.value || address; | |
| 515 | - elements.transferActor.value = elements.transferActor.value || address; | ||
| 516 | 537 | setWalletStatus(`Connected: ${address}`); | |
| 538 | + updateActorOptions(); | ||
| 517 | 539 | return address; | |
| 518 | 540 | } | |
| 519 | 541 | ||
@@ -694,7 +716,19 @@ async function handleGetPipe() { | |||
| 694 | 716 | resultHex, | |
| 695 | 717 | decoded, | |
| 696 | 718 | }); | |
| 697 | - appendLog("Fetched pipe state via read-only get-pipe."); | ||
| 719 | + | ||
| 720 | + // Populate Pipe State fields when we get valid data | ||
| 721 | + if (decoded && decoded.nonce !== undefined) { | ||
| 722 | + elements.pipeNonce.value = decoded.nonce; | ||
| 723 | + const pair = canonicalPrincipals(forPrincipal, withPrincipal); | ||
| 724 | + const iAmP1 = pair.principal1 === forPrincipal; | ||
| 725 | + elements.pipeMyBalance.value = iAmP1 ? (decoded["balance-1"] ?? "0") : (decoded["balance-2"] ?? "0"); | ||
| 726 | + elements.pipeTheirBalance.value = iAmP1 ? (decoded["balance-2"] ?? "0") : (decoded["balance-1"] ?? "0"); | ||
| 727 | + updatePreview(); | ||
| 728 | + appendLog("Fetched pipe state and populated fields."); | ||
| 729 | + } else { | ||
| 730 | + appendLog("Fetched pipe state via read-only get-pipe."); | ||
| 731 | + } | ||
| 698 | 732 | } catch (error) { | |
| 699 | 733 | const message = error instanceof Error ? error.message : String(error); | |
| 700 | 734 | setOutput(`Error: ${message}`); | |
@@ -794,6 +828,102 @@ async function handleForceCancel() { | |||
| 794 | 828 | } | |
| 795 | 829 | } | |
| 796 | 830 | ||
| 831 | + function computeAutoResult() { | ||
| 832 | + const actionType = normalizedText(elements.actionType.value); | ||
| 833 | + const pipeNonce = parseUintInput("Nonce", elements.pipeNonce.value); | ||
| 834 | + const pipeMyBalance = parseUintInput("My Balance", elements.pipeMyBalance.value); | ||
| 835 | + const pipeTheirBalance = parseUintInput("Their Balance", elements.pipeTheirBalance.value); | ||
| 836 | + const amount = parseUintInput("Amount", elements.actionAmount.value); | ||
| 837 | + | ||
| 838 | + if (actionType === "transfer-to") { | ||
| 839 | + if (amount > pipeMyBalance) throw new Error("Amount exceeds my balance"); | ||
| 840 | + return { nonce: pipeNonce + 1n, myBalance: pipeMyBalance - amount, theirBalance: pipeTheirBalance + amount, actionCode: 1n }; | ||
| 841 | + } | ||
| 842 | + if (actionType === "transfer-from") { | ||
| 843 | + if (amount > pipeTheirBalance) throw new Error("Amount exceeds their balance"); | ||
| 844 | + return { nonce: pipeNonce + 1n, myBalance: pipeMyBalance + amount, theirBalance: pipeTheirBalance - amount, actionCode: 1n }; | ||
| 845 | + } | ||
| 846 | + if (actionType === "close") { | ||
| 847 | + return { nonce: pipeNonce + 1n, myBalance: pipeMyBalance, theirBalance: pipeTheirBalance, actionCode: 0n }; | ||
| 848 | + } | ||
| 849 | + throw new Error(`Unknown action type: ${actionType}`); | ||
| 850 | + } | ||
| 851 | + | ||
| 852 | + function updatePreview() { | ||
| 853 | + try { | ||
| 854 | + const result = computeAutoResult(); | ||
| 855 | + elements.resultNonce.value = result.nonce.toString(); | ||
| 856 | + elements.resultMyBalance.value = result.myBalance.toString(); | ||
| 857 | + elements.resultTheirBalance.value = result.theirBalance.toString(); | ||
| 858 | + elements.resultActionCode.value = result.actionCode.toString(); | ||
| 859 | + } catch { | ||
| 860 | + // leave fields as-is on error | ||
| 861 | + } | ||
| 862 | + } | ||
| 863 | + | ||
| 864 | + function truncateAddr(addr) { | ||
| 865 | + const t = normalizedText(addr); | ||
| 866 | + if (!t) return ""; | ||
| 867 | + return t.length > 14 ? `${t.slice(0, 8)}…${t.slice(-4)}` : t; | ||
| 868 | + } | ||
| 869 | + | ||
| 870 | + function updateActorOptions() { | ||
| 871 | + const myRaw = normalizedText(elements.forPrincipal.value) || state.connectedAddress || ""; | ||
| 872 | + const themRaw = normalizedText(elements.counterparty.value) || ""; | ||
| 873 | + const opts = elements.transferActor.options; | ||
| 874 | + opts[0].text = myRaw ? `Me — ${truncateAddr(myRaw)}` : "Me"; | ||
| 875 | + opts[1].text = themRaw ? `Them — ${truncateAddr(themRaw)}` : "Them"; | ||
| 876 | + } | ||
| 877 | + | ||
| 878 | + function handleActorChange() { | ||
| 879 | + const isCustom = normalizedText(elements.transferActor.value) === "custom"; | ||
| 880 | + elements.actorCustomRow.classList.toggle("hidden", !isCustom); | ||
| 881 | + } | ||
| 882 | + | ||
| 883 | + function handleActionTypeChange() { | ||
| 884 | + const actionType = normalizedText(elements.actionType.value); | ||
| 885 | + const hasAmount = actionType === "transfer-to" || actionType === "transfer-from"; | ||
| 886 | + elements.actionAmountRow.classList.toggle("hidden", !hasAmount); | ||
| 887 | + // Auto-select actor based on action | ||
| 888 | + if (actionType === "transfer-to") { | ||
| 889 | + elements.transferActor.value = "me"; | ||
| 890 | + } else if (actionType === "transfer-from") { | ||
| 891 | + elements.transferActor.value = "them"; | ||
| 892 | + } else if (actionType === "close") { | ||
| 893 | + elements.transferActor.value = "me"; | ||
| 894 | + } | ||
| 895 | + handleActorChange(); | ||
| 896 | + updatePreview(); | ||
| 897 | + } | ||
| 898 | + | ||
| 899 | + function cvToBytes(cv) { | ||
| 900 | + const result = serializeCV(cv); | ||
| 901 | + // serializeCV returns a hex string in stacks.js v7 | ||
| 902 | + if (typeof result === "string") { | ||
| 903 | + const hex = result.startsWith("0x") ? result.slice(2) : result; | ||
| 904 | + const bytes = new Uint8Array(hex.length / 2); | ||
| 905 | + for (let i = 0; i < hex.length; i += 2) { | ||
| 906 | + bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); | ||
| 907 | + } | ||
| 908 | + return bytes; | ||
| 909 | + } | ||
| 910 | + return result; | ||
| 911 | + } | ||
| 912 | + | ||
| 913 | + async function computeStructuredDataHash(domain, message) { | ||
| 914 | + // SIP-018: sha256("SIP018" || sha256(domain_bytes) || sha256(message_bytes)) | ||
| 915 | + const prefix = new Uint8Array([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); | ||
| 916 | + const [domainHashBuf, messageHashBuf] = await Promise.all([ | ||
| 917 | + crypto.subtle.digest("SHA-256", cvToBytes(domain)), | ||
| 918 | + crypto.subtle.digest("SHA-256", cvToBytes(message)), | ||
| 919 | + ]); | ||
| 920 | + const payload = new Uint8Array(prefix.length + 32 + 32); | ||
| 921 | + payload.set(prefix, 0); | ||
| 922 | + payload.set(new Uint8Array(domainHashBuf), prefix.length); | ||
| 923 | + payload.set(new Uint8Array(messageHashBuf), prefix.length + 32); | ||
| 924 | + return new Uint8Array(await crypto.subtle.digest("SHA-256", payload)); | ||
| 925 | + } | ||
| 926 | + | ||
| 797 | 927 | async function buildTransferContext() { | |
| 798 | 928 | const network = readNetwork(); | |
| 799 | 929 | const { contractId } = parseContractId(); | |
@@ -805,14 +935,15 @@ async function buildTransferContext() { | |||
| 805 | 935 | "Counterparty", | |
| 806 | 936 | elements.counterparty.value, | |
| 807 | 937 | ); | |
| 808 | - const actor = await resolvePrincipalInput( | ||
| 809 | - "Actor Principal", | ||
| 810 | - elements.transferActor.value || forPrincipal, | ||
| 811 | - ); | ||
| 812 | - const myBalance = parseUintInput("My Balance", elements.myBalance.value); | ||
| 813 | - const theirBalance = parseUintInput("Their Balance", elements.theirBalance.value); | ||
| 814 | - const nonce = parseUintInput("Nonce", elements.transferNonce.value); | ||
| 815 | - const action = parseUintInput("Action", elements.transferAction.value); | ||
| 938 | + const actorMode = normalizedText(elements.transferActor.value); | ||
| 939 | + const actor = | ||
| 940 | + actorMode === "me" ? forPrincipal | ||
| 941 | + : actorMode === "them" ? withPrincipal | ||
| 942 | + : await resolvePrincipalInput("Custom Actor", elements.transferActorCustom.value); | ||
| 943 | + const myBalance = parseUintInput("My Resulting Balance", elements.resultMyBalance.value); | ||
| 944 | + const theirBalance = parseUintInput("Their Resulting Balance", elements.resultTheirBalance.value); | ||
| 945 | + const nonce = parseUintInput("Resulting Nonce", elements.resultNonce.value); | ||
| 946 | + const action = parseUintInput("Action Code", elements.resultActionCode.value); | ||
| 816 | 947 | const { cv: tokenCV, tokenText } = parseOptionalTokenCV(); | |
| 817 | 948 | const { cv: hashedSecretCV, text: hashedSecretText } = parseHashedSecretCV(); | |
| 818 | 949 | const { cv: validAfterCV, text: validAfterText } = parseValidAfterCV(); | |
@@ -872,6 +1003,7 @@ async function handleSignTransfer() { | |||
| 872 | 1003 | throw new Error("Wallet did not return a signature"); | |
| 873 | 1004 | } | |
| 874 | 1005 | state.lastSignature = signature; | |
| 1006 | + elements.mySignature.value = signature; | ||
| 875 | 1007 | ||
| 876 | 1008 | const payload = { | |
| 877 | 1009 | contractId: context.contractId, | |
@@ -924,6 +1056,82 @@ async function handleBuildPayload() { | |||
| 924 | 1056 | } | |
| 925 | 1057 | } | |
| 926 | 1058 | ||
| 1059 | + function showValidationResult(success, text) { | ||
| 1060 | + elements.validationResult.classList.remove("hidden"); | ||
| 1061 | + elements.validationIcon.textContent = success ? "✓" : "✗"; | ||
| 1062 | + elements.validationIcon.className = `validation-icon ${success ? "ok" : "fail"}`; | ||
| 1063 | + elements.validationText.textContent = text; | ||
| 1064 | + } | ||
| 1065 | + | ||
| 1066 | + async function handleValidateSignature() { | ||
| 1067 | + try { | ||
| 1068 | + const sigInput = normalizedText(elements.validateSignature.value); | ||
| 1069 | + if (!sigInput) throw new Error("No signature to validate"); | ||
| 1070 | + | ||
| 1071 | + const sig = sigInput.startsWith("0x") ? sigInput.slice(2) : sigInput; | ||
| 1072 | + if (!/^[0-9a-fA-F]{130}$/.test(sig)) { | ||
| 1073 | + throw new Error("Signature must be 65 bytes (130 hex chars)"); | ||
| 1074 | + } | ||
| 1075 | + | ||
| 1076 | + const context = await buildTransferContext(); | ||
| 1077 | + const hashBytes = await computeStructuredDataHash(context.domain, context.message); | ||
| 1078 | + const hashHex = toHex(hashBytes); | ||
| 1079 | + | ||
| 1080 | + const network = readNetwork(); | ||
| 1081 | + const forAddr = context.forPrincipal.split(".")[0]; | ||
| 1082 | + const withAddr = context.withPrincipal.split(".")[0]; | ||
| 1083 | + | ||
| 1084 | + // Try as-is (RSV), then with recovery byte moved from front to back (VRS→RSV) | ||
| 1085 | + const candidates = [sig, sig.slice(2) + sig.slice(0, 2)]; | ||
| 1086 | + let recoveredAddress = null; | ||
| 1087 | + let isParticipant = false; | ||
| 1088 | + let label = ""; | ||
| 1089 | + | ||
| 1090 | + for (const candidate of candidates) { | ||
| 1091 | + try { | ||
| 1092 | + const pubKey = publicKeyFromSignatureRsv(hashHex, candidate); | ||
| 1093 | + const addr = getAddressFromPublicKey(pubKey, network); | ||
| 1094 | + if (recoveredAddress === null) recoveredAddress = addr; | ||
| 1095 | + if (addr === forAddr) { | ||
| 1096 | + recoveredAddress = addr; | ||
| 1097 | + label = `Signed by ME — ${addr}`; | ||
| 1098 | + isParticipant = true; | ||
| 1099 | + break; | ||
| 1100 | + } | ||
| 1101 | + if (addr === withAddr) { | ||
| 1102 | + recoveredAddress = addr; | ||
| 1103 | + label = `Signed by COUNTERPARTY — ${addr}`; | ||
| 1104 | + isParticipant = true; | ||
| 1105 | + break; | ||
| 1106 | + } | ||
| 1107 | + } catch { | ||
| 1108 | + // try next format | ||
| 1109 | + } | ||
| 1110 | + } | ||
| 1111 | + | ||
| 1112 | + if (!recoveredAddress) throw new Error("Could not recover signer from signature"); | ||
| 1113 | + if (!isParticipant) label = `Unknown signer — ${recoveredAddress}`; | ||
| 1114 | + | ||
| 1115 | + showValidationResult(isParticipant, label); | ||
| 1116 | + setOutput({ recoveredAddress, isParticipant, label }); | ||
| 1117 | + appendLog(`Signature validation: ${label}`); | ||
| 1118 | + } catch (error) { | ||
| 1119 | + const message = error instanceof Error ? error.message : String(error); | ||
| 1120 | + showValidationResult(false, message); | ||
| 1121 | + setOutput(`Error: ${message}`); | ||
| 1122 | + appendLog(`Validate signature failed: ${message}`, { error: true }); | ||
| 1123 | + } | ||
| 1124 | + } | ||
| 1125 | + | ||
| 1126 | + function handleUseMySignature() { | ||
| 1127 | + const sig = normalizedText(elements.mySignature.value) || state.lastSignature; | ||
| 1128 | + if (!sig) { | ||
| 1129 | + appendLog("No signature generated yet — sign with wallet first.", { error: true }); | ||
| 1130 | + return; | ||
| 1131 | + } | ||
| 1132 | + elements.validateSignature.value = sig; | ||
| 1133 | + } | ||
| 1134 | + | ||
| 927 | 1135 | async function handleCopyOutput() { | |
| 928 | 1136 | try { | |
| 929 | 1137 | await navigator.clipboard.writeText(elements.output.textContent || ""); | |
@@ -942,7 +1150,18 @@ function wireEvents() { | |||
| 942 | 1150 | elements.forceCancel.addEventListener("click", handleForceCancel); | |
| 943 | 1151 | elements.signTransfer.addEventListener("click", handleSignTransfer); | |
| 944 | 1152 | elements.buildPayload.addEventListener("click", handleBuildPayload); | |
| 1153 | + elements.validateSigBtn.addEventListener("click", handleValidateSignature); | ||
| 1154 | + elements.useMySignatureBtn.addEventListener("click", handleUseMySignature); | ||
| 945 | 1155 | elements.copyOutput.addEventListener("click", handleCopyOutput); | |
| 1156 | + // Pipe State / Action preview live updates | ||
| 1157 | + elements.actionType.addEventListener("change", handleActionTypeChange); | ||
| 1158 | + elements.transferActor.addEventListener("change", handleActorChange); | ||
| 1159 | + for (const id of ["pipe-nonce", "pipe-my-balance", "pipe-their-balance", "action-amount"]) { | ||
| 1160 | + document.getElementById(id).addEventListener("input", updatePreview); | ||
| 1161 | + } | ||
| 1162 | + // Refresh actor option labels when principals change | ||
| 1163 | + elements.forPrincipal.addEventListener("input", updateActorOptions); | ||
| 1164 | + elements.counterparty.addEventListener("input", updateActorOptions); | ||
| 946 | 1165 | elements.network.addEventListener("change", () => { | |
| 947 | 1166 | const previousPresetKey = elements.contractPreset.value; | |
| 948 | 1167 | elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[readNetwork()]; | |
@@ -983,6 +1202,8 @@ function wireEvents() { | |||
| 983 | 1202 | ||
| 984 | 1203 | async function bootstrap() { | |
| 985 | 1204 | wireEvents(); | |
| 1205 | + handleActionTypeChange(); | ||
| 1206 | + updateActorOptions(); | ||
| 986 | 1207 | updateNetworkDefaults(); | |
| 987 | 1208 | renderPresetOptions(); | |
| 988 | 1209 | if (!normalizedText(elements.contractId.value) && !normalizedText(elements.tokenContract.value)) { | |
| Back | FazBrowse Home | New Git URL |
0 commit comments