Skip to content

Commit 6789442

Browse files
committed
feat: more improvements to the pipe console
1 parent 941042b commit 6789442

File tree

3 files changed

+422
-28
lines changed

3 files changed

+422
-28
lines changed

docs/app.js

Lines changed: 235 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import {
1010
Pc,
1111
cvToJSON,
1212
fetchCallReadOnlyFunction,
13+
getAddressFromPublicKey,
1314
principalCV,
15+
publicKeyFromSignatureRsv,
1416
serializeCV,
1517
} from "https://esm.sh/@stacks/transactions@7.2.0?bundle&target=es2020";
1618

@@ -83,13 +85,33 @@ const elements = {
8385
forPrincipal: document.getElementById("for-principal"),
8486
openAmount: document.getElementById("open-amount"),
8587
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
90101
transferActor: document.getElementById("transfer-actor"),
102+
actorCustomRow: document.getElementById("actor-custom-row"),
103+
transferActorCustom: document.getElementById("transfer-actor-custom"),
91104
transferSecret: document.getElementById("transfer-secret"),
92105
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
93115
walletStatus: document.getElementById("wallet-status"),
94116
connectWallet: document.getElementById("connect-wallet"),
95117
disconnectWallet: document.getElementById("disconnect-wallet"),
@@ -512,8 +534,8 @@ async function ensureWallet({ interactive }) {
512534
}
513535
state.connectedAddress = address;
514536
elements.forPrincipal.value = elements.forPrincipal.value || address;
515-
elements.transferActor.value = elements.transferActor.value || address;
516537
setWalletStatus(`Connected: ${address}`);
538+
updateActorOptions();
517539
return address;
518540
}
519541

@@ -694,7 +716,19 @@ async function handleGetPipe() {
694716
resultHex,
695717
decoded,
696718
});
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+
}
698732
} catch (error) {
699733
const message = error instanceof Error ? error.message : String(error);
700734
setOutput(`Error: ${message}`);
@@ -794,6 +828,102 @@ async function handleForceCancel() {
794828
}
795829
}
796830

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+
797927
async function buildTransferContext() {
798928
const network = readNetwork();
799929
const { contractId } = parseContractId();
@@ -805,14 +935,15 @@ async function buildTransferContext() {
805935
"Counterparty",
806936
elements.counterparty.value,
807937
);
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);
816947
const { cv: tokenCV, tokenText } = parseOptionalTokenCV();
817948
const { cv: hashedSecretCV, text: hashedSecretText } = parseHashedSecretCV();
818949
const { cv: validAfterCV, text: validAfterText } = parseValidAfterCV();
@@ -872,6 +1003,7 @@ async function handleSignTransfer() {
8721003
throw new Error("Wallet did not return a signature");
8731004
}
8741005
state.lastSignature = signature;
1006+
elements.mySignature.value = signature;
8751007

8761008
const payload = {
8771009
contractId: context.contractId,
@@ -924,6 +1056,82 @@ async function handleBuildPayload() {
9241056
}
9251057
}
9261058

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+
9271135
async function handleCopyOutput() {
9281136
try {
9291137
await navigator.clipboard.writeText(elements.output.textContent || "");
@@ -942,7 +1150,18 @@ function wireEvents() {
9421150
elements.forceCancel.addEventListener("click", handleForceCancel);
9431151
elements.signTransfer.addEventListener("click", handleSignTransfer);
9441152
elements.buildPayload.addEventListener("click", handleBuildPayload);
1153+
elements.validateSigBtn.addEventListener("click", handleValidateSignature);
1154+
elements.useMySignatureBtn.addEventListener("click", handleUseMySignature);
9451155
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);
9461165
elements.network.addEventListener("change", () => {
9471166
const previousPresetKey = elements.contractPreset.value;
9481167
elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[readNetwork()];
@@ -983,6 +1202,8 @@ function wireEvents() {
9831202

9841203
async function bootstrap() {
9851204
wireEvents();
1205+
handleActionTypeChange();
1206+
updateActorOptions();
9861207
updateNetworkDefaults();
9871208
renderPresetOptions();
9881209
if (!normalizedText(elements.contractId.value) && !normalizedText(elements.tokenContract.value)) {

0 commit comments

Comments
 (0)