@@ -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+
797927async 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 - 9 a - f A - 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+
9271135async 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
9841203async 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