From 6aa4f2e000b55b21488fe5b1517b9371099bef7e Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 10 May 2025 09:31:12 -0400 Subject: [PATCH 01/78] test: force-close with unused signatures Signatures collected for `deposit` or `withdraw` but then never used on-chain cannot be used to close the channel. --- tests/stackflow.test.ts | 142 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 8f750b0..8173a1c 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -2369,6 +2369,148 @@ describe("force-close", () => { expect(result).toBeErr(Cl.uint(TxError.NotValidYet)); }); + + it("cannot force-close with deposit signatures that were never applied", () => { + // Initialize the contract for STX + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + const { result: fundResult } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + expect(fundResult).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(address2), + }) + ); + + // Wait for the funds to confirm + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + // Create the signatures for a deposit + const signature1 = generateDepositSignature( + address1PK, + null, + address1, + address2, + 1050000, + 2000000, + 1, + address1 + ); + const signature2 = generateDepositSignature( + address2PK, + null, + address2, + address1, + 2000000, + 1050000, + 1, + address1 + ); + + const { result } = simnet.callPublicFn( + "stackflow", + "force-close", + [ + Cl.none(), + Cl.principal(address2), + Cl.uint(1050000), + Cl.uint(2000000), + Cl.buffer(signature1), + Cl.buffer(signature2), + Cl.uint(1), + Cl.uint(PipeAction.Deposit), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(TxError.InvalidTotalBalance)); + }); + + it("cannot force-close with withdraw signatures that were never applied", () => { + // Initialize the contract for STX + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + const { result: fundResult } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + expect(fundResult).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(address2), + }) + ); + + // Wait for the funds to confirm + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + // Create the signatures for a withdraw + const signature1 = generateWithdrawSignature( + address1PK, + null, + address1, + address2, + 500000, + 2000000, + 1, + address1 + ); + const signature2 = generateWithdrawSignature( + address2PK, + null, + address2, + address1, + 2000000, + 500000, + 1, + address1 + ); + + const { result } = simnet.callPublicFn( + "stackflow", + "force-close", + [ + Cl.none(), + Cl.principal(address2), + Cl.uint(1050000), + Cl.uint(2000000), + Cl.buffer(signature1), + Cl.buffer(signature2), + Cl.uint(1), + Cl.uint(PipeAction.Withdraw), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(TxError.InvalidTotalBalance)); + }); }); describe("dispute-closure", () => { From e048a23727957f2f7ee20190ce5f3a1e42988b3c Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 10 May 2025 10:00:59 -0400 Subject: [PATCH 02/78] chore: formatting Formatted with clarinet! --- contracts/stackflow.clar | 794 +++++++++++++++++++-------------------- 1 file changed, 395 insertions(+), 399 deletions(-) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 7c7e4c8..691d22b 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -38,13 +38,11 @@ ;; Constants for SIP-018 structured data (define-constant structured-data-prefix 0x534950303138) -(define-constant message-domain-hash (sha256 (unwrap-panic (to-consensus-buff? - { - name: "StackFlow", - version: "0.6.0", - chain-id: chain-id - } -)))) +(define-constant message-domain-hash (sha256 (unwrap-panic (to-consensus-buff? { + name: "StackFlow", + version: "0.6.0", + chain-id: chain-id, +})))) (define-constant structured-data-header (concat structured-data-prefix message-domain-hash)) ;; Actions @@ -92,22 +90,34 @@ ;;; Map tracking the initial balances in pipes between two principals for a ;;; given token. -(define-map - pipes - { token: (optional principal), principal-1: principal, principal-2: principal } +(define-map pipes + { + token: (optional principal), + principal-1: principal, + principal-2: principal, + } { balance-1: uint, balance-2: uint, - pending-1: (optional { amount: uint, burn-height: uint }), - pending-2: (optional { amount: uint, burn-height: uint }), + pending-1: (optional { + amount: uint, + burn-height: uint, + }), + pending-2: (optional { + amount: uint, + burn-height: uint, + }), expires-at: uint, nonce: uint, - closer: (optional principal) + closer: (optional principal), } ) ;; Mapping of principals to agents registered to act on their behalf -(define-map agents principal principal) +(define-map agents + principal + principal +) ;; Public Functions ;; @@ -160,30 +170,30 @@ ;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce ;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is in progress ;;; - `ERR_ALREADY_FUNDED` if the pipe has already been funded -(define-public (fund-pipe (token (optional )) (amount uint) (with principal) (nonce uint)) +(define-public (fund-pipe + (token (optional )) + (amount uint) + (with principal) + (nonce uint) + ) (begin (try! (check-token token)) - - (let - ( + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (existing-pipe (map-get? pipes pipe-key)) - (pipe - (match - existing-pipe - ch - ch - { - balance-1: u0, - balance-2: u0, - pending-1: none, - pending-2: none, - expires-at: MAX_HEIGHT, - nonce: nonce, - closer: none, - } - ) - ) + (pipe (match existing-pipe + ch + ch + { + balance-1: u0, + balance-2: u0, + pending-1: none, + pending-2: none, + expires-at: MAX_HEIGHT, + nonce: nonce, + closer: none, + } + )) (updated-pipe (try! (increase-sender-balance pipe-key pipe token amount))) (closer (get closer pipe)) ) @@ -197,8 +207,9 @@ ;; Only fund a pipe with a 0 balance for the sender can be funded. After ;; the pipe is initially funded, additional funds must use the `deposit` ;; function, which requires signatures from both parties. - (asserts! (not (is-funded tx-sender pipe-key updated-pipe)) ERR_ALREADY_FUNDED) - + (asserts! (not (is-funded tx-sender pipe-key updated-pipe)) + ERR_ALREADY_FUNDED + ) (map-set pipes pipe-key updated-pipe) ;; Emit an event @@ -232,20 +243,25 @@ (their-signature (buff 65)) (nonce uint) ) - (let - ( + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (pipe-nonce (get nonce pipe)) (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq tx-sender principal-1) my-balance their-balance)) - (balance-2 (if (is-eq tx-sender principal-1) their-balance my-balance)) + (balance-1 (if (is-eq tx-sender principal-1) + my-balance + their-balance + )) + (balance-2 (if (is-eq tx-sender principal-1) + their-balance + my-balance + )) (updated-pipe { balance-1: balance-1, balance-2: balance-2, expires-at: MAX_HEIGHT, nonce: nonce, - closer: none + closer: none, }) (settled-pipe (settle-pending pipe-key pipe)) ) @@ -254,8 +270,10 @@ (asserts! (and (is-none (get pending-1 settled-pipe)) - (is-none (get pending-2 settled-pipe))) - ERR_PENDING) + (is-none (get pending-2 settled-pipe)) + ) + ERR_PENDING + ) ;; The nonce must be greater than the pipe's saved nonce (asserts! (> nonce pipe-nonce) ERR_NONCE_TOO_LOW) @@ -263,31 +281,16 @@ ;; If the total balance of the pipe is not equal to the sum of the ;; balances provided, the pipe close is invalid. (asserts! - (is-eq - (+ my-balance their-balance) + (is-eq (+ my-balance their-balance) (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) ) ERR_INVALID_TOTAL_BALANCE ) ;; Verify the signatures of the two parties. - (try! - (verify-signatures - my-signature - tx-sender - their-signature - with - pipe-key - balance-1 - balance-2 - nonce - ACTION_CLOSE - tx-sender - none - none - ) - ) - + (try! (verify-signatures my-signature tx-sender their-signature with pipe-key + balance-1 balance-2 nonce ACTION_CLOSE tx-sender none none + )) ;; Reset the pipe in the map. (reset-pipe pipe-key nonce) @@ -313,9 +316,11 @@ ;;; which the pipe can be finalized if it has not been disputed. ;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist ;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is already in progress -(define-public (force-cancel (token (optional )) (with principal)) - (let - ( +(define-public (force-cancel + (token (optional )) + (with principal) + ) + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (closer (get closer pipe)) @@ -329,14 +334,16 @@ (asserts! (and (is-none (get pending-1 settled-pipe)) - (is-none (get pending-2 settled-pipe))) - ERR_PENDING) - + (is-none (get pending-2 settled-pipe)) + ) + ERR_PENDING + ) ;; Set the waiting period for this pipe. - (map-set - pipes - pipe-key - (merge settled-pipe { expires-at: expires-at, closer: (some tx-sender) }) + (map-set pipes pipe-key + (merge settled-pipe { + expires-at: expires-at, + closer: (some tx-sender), + }) ) ;; Emit an event @@ -378,8 +385,7 @@ (secret (optional (buff 32))) (valid-after (optional uint)) ) - (let - ( + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (pipe-nonce (get nonce pipe)) @@ -393,8 +399,10 @@ (asserts! (and (is-none (get pending-1 settled-pipe)) - (is-none (get pending-2 settled-pipe))) - ERR_PENDING) + (is-none (get pending-2 settled-pipe)) + ) + ERR_PENDING + ) ;; Exit early if the nonce is less than the pipe's nonce (asserts! (> nonce pipe-nonce) ERR_NONCE_TOO_LOW) @@ -408,19 +416,22 @@ ;; If the total balance of the pipe is not equal to the sum of the ;; balances provided, the pipe close is invalid. (asserts! - (is-eq - (+ my-balance their-balance) + (is-eq (+ my-balance their-balance) (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) ) ERR_INVALID_TOTAL_BALANCE ) - - (let - ( + (let ( (expires-at (+ burn-block-height WAITING_PERIOD)) (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq tx-sender principal-1) my-balance their-balance)) - (balance-2 (if (is-eq tx-sender principal-1) their-balance my-balance)) + (balance-1 (if (is-eq tx-sender principal-1) + my-balance + their-balance + )) + (balance-2 (if (is-eq tx-sender principal-1) + their-balance + my-balance + )) (new-pipe { balance-1: balance-1, balance-2: balance-2, @@ -428,34 +439,17 @@ pending-2: none, expires-at: expires-at, closer: (some tx-sender), - nonce: nonce + nonce: nonce, }) ) ;; Verify the signatures of the two parties. - (try! - (verify-signatures - my-signature - tx-sender - their-signature - with - pipe-key - balance-1 - balance-2 - nonce - action - actor - secret - valid-after - ) - ) + (try! (verify-signatures my-signature tx-sender their-signature with pipe-key + balance-1 balance-2 nonce action actor secret valid-after + )) ;; Set the waiting period for this pipe. - (map-set - pipes - pipe-key - new-pipe - ) + (map-set pipes pipe-key new-pipe) ;; Emit an event (print { @@ -500,19 +494,8 @@ (secret (optional (buff 32))) (valid-after (optional uint)) ) - (dispute-closure-inner - tx-sender - token - with - my-balance - their-balance - my-signature - their-signature - nonce - action - actor - secret - valid-after + (dispute-closure-inner tx-sender token with my-balance their-balance + my-signature their-signature nonce action actor secret valid-after ) ) @@ -548,24 +531,10 @@ (secret (optional (buff 32))) (valid-after (optional uint)) ) - (let - ( - (agent (unwrap! (map-get? agents for) ERR_UNAUTHORIZED)) - ) + (let ((agent (unwrap! (map-get? agents for) ERR_UNAUTHORIZED))) (asserts! (is-eq tx-sender agent) ERR_UNAUTHORIZED) - (dispute-closure-inner - for - token - with - my-balance - their-balance - my-signature - their-signature - nonce - action - actor - secret - valid-after + (dispute-closure-inner for token with my-balance their-balance my-signature + their-signature nonce action actor secret valid-after ) ) ) @@ -579,9 +548,11 @@ ;;; - `ERR_NO_CLOSE_IN_PROGRESS` if a forced closure is not in progress ;;; - `ERR_NOT_EXPIRED` if the waiting period has not passed ;;; - `ERR_WITHDRAWAL_FAILED` if the withdrawal fails -(define-public (finalize (token (optional )) (with principal)) - (let - ( +(define-public (finalize + (token (optional )) + (with principal) + ) + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (closer (get closer pipe)) @@ -604,12 +575,8 @@ sender: tx-sender, }) - (payout - token - (get principal-1 pipe-key) - (get principal-2 pipe-key) - (get balance-1 pipe) - (get balance-2 pipe) + (payout token (get principal-1 pipe-key) (get principal-2 pipe-key) + (get balance-1 pipe) (get balance-2 pipe) ) ) ) @@ -637,8 +604,7 @@ (their-signature (buff 65)) (nonce uint) ) - (let - ( + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (pipe-nonce (get nonce pipe)) @@ -651,8 +617,14 @@ ;; These are the balances that both parties have signed off on, including ;; the deposit amount. - (balance-1 (if (is-eq tx-sender principal-1) my-balance their-balance)) - (balance-2 (if (is-eq tx-sender principal-1) their-balance my-balance)) + (balance-1 (if (is-eq tx-sender principal-1) + my-balance + their-balance + )) + (balance-2 (if (is-eq tx-sender principal-1) + their-balance + my-balance + )) (settled-pipe (settle-pending pipe-key pipe)) @@ -660,30 +632,27 @@ ;; the deposit is pending. (pre-balance-1 (if (is-eq tx-sender principal-1) (- my-balance amount) - (- their-balance (match (get pending-1 settled-pipe) - pending (get amount pending) - u0) - ) + (- their-balance + (match (get pending-1 settled-pipe) + pending (get amount pending) + u0 + )) )) (pre-balance-2 (if (is-eq tx-sender principal-1) - (- their-balance (match (get pending-2 settled-pipe) - pending (get amount pending) - u0) - ) + (- their-balance + (match (get pending-2 settled-pipe) + pending (get amount pending) + u0 + )) (- my-balance amount) )) (updated-pipe (try! (increase-sender-balance pipe-key settled-pipe token amount))) - (result-pipe - (merge - updated-pipe - { - balance-1: pre-balance-1, - balance-2: pre-balance-2, - nonce: nonce - } - ) - ) + (result-pipe (merge updated-pipe { + balance-1: pre-balance-1, + balance-2: pre-balance-2, + nonce: nonce, + })) ) ;; A forced closure must not be in progress (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) @@ -695,11 +664,8 @@ ;; existing balances and the deposit amount, the deposit is invalid. ;; Previously pending balances are included in the calculation. (asserts! - (is-eq - (+ my-balance their-balance) - (+ - (get balance-1 settled-pipe) - (get balance-2 settled-pipe) + (is-eq (+ my-balance their-balance) + (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe) (match (get pending-1 settled-pipe) pending (get amount pending) u0 @@ -709,35 +675,17 @@ u0 ) amount - ) - ) + )) ERR_INVALID_TOTAL_BALANCE ) ;; Update the pipe with the new balances and nonce. - (map-set - pipes - pipe-key - result-pipe - ) + (map-set pipes pipe-key result-pipe) ;; Verify the signatures of the two parties. - (try! - (verify-signatures - my-signature - tx-sender - their-signature - with - pipe-key - balance-1 - balance-2 - nonce - ACTION_DEPOSIT - tx-sender - none - none - ) - ) + (try! (verify-signatures my-signature tx-sender their-signature with pipe-key + balance-1 balance-2 nonce ACTION_DEPOSIT tx-sender none none + )) (print { event: "deposit", @@ -775,29 +723,28 @@ (their-signature (buff 65)) (nonce uint) ) - (let - ( + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (pipe-nonce (get nonce pipe)) (closer (get closer pipe)) (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq tx-sender principal-1) my-balance their-balance)) - (balance-2 (if (is-eq tx-sender principal-1) their-balance my-balance)) + (balance-1 (if (is-eq tx-sender principal-1) + my-balance + their-balance + )) + (balance-2 (if (is-eq tx-sender principal-1) + their-balance + my-balance + )) ;; Settle any pending deposits that may be in progress (settled-pipe (settle-pending pipe-key pipe)) - (updated-pipe - (merge - settled-pipe - { - balance-1: balance-1, - balance-2: balance-2, - nonce: nonce - } - ) - ) + (updated-pipe (merge settled-pipe { + balance-1: balance-1, + balance-2: balance-2, + nonce: nonce, + })) ) - ;; A forced closure must not be in progress (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) @@ -813,37 +760,19 @@ ;; If the new balance of the pipe is not equal to the sum of the ;; prior balances minus the withdraw amount, the withdrawal is invalid. (asserts! - (is-eq - (+ my-balance their-balance) + (is-eq (+ my-balance their-balance) (- (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) amount) ) ERR_INVALID_TOTAL_BALANCE ) ;; Update the pipe with the new balances and nonce. - (map-set - pipes - pipe-key - updated-pipe - ) + (map-set pipes pipe-key updated-pipe) ;; Verify the signatures of the two parties. - (try! - (verify-signatures - my-signature - tx-sender - their-signature - with - pipe-key - balance-1 - balance-2 - nonce - ACTION_WITHDRAWAL - tx-sender - none - none - ) - ) + (try! (verify-signatures my-signature tx-sender their-signature with pipe-key + balance-1 balance-2 nonce ACTION_WITHDRAWAL tx-sender none none + )) ;; Perform the withdraw (try! (execute-withdraw token amount)) @@ -878,7 +807,10 @@ ;;; }) ;;; ``` ;;; - `none` if the pipe does not exist -(define-read-only (get-pipe (token (optional principal)) (with principal)) +(define-read-only (get-pipe + (token (optional principal)) + (with principal) + ) (match (get-pipe-key token tx-sender with) pipe-key (map-get? pipes pipe-key) e none @@ -891,7 +823,11 @@ ;;; - `ERR_CONSENSUS_BUFF` if the structured data cannot be converted to a ;;; consensus buff (define-read-only (make-structured-data-hash - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (balance-1 uint) (balance-2 uint) (nonce uint) @@ -900,20 +836,16 @@ (hashed-secret (optional (buff 32))) (valid-after (optional uint)) ) - (let - ( - (structured-data (merge - pipe-key - { - balance-1: balance-1, - balance-2: balance-2, - nonce: nonce, - action: action, - actor: actor, - hashed-secret: hashed-secret, - valid-after: valid-after, - } - )) + (let ( + (structured-data (merge pipe-key { + balance-1: balance-1, + balance-2: balance-2, + nonce: nonce, + action: action, + actor: actor, + hashed-secret: hashed-secret, + valid-after: valid-after, + })) (data-hash (sha256 (unwrap! (to-consensus-buff? structured-data) ERR_CONSENSUS_BUFF))) ) (ok (sha256 (concat structured-data-header data-hash))) @@ -928,7 +860,11 @@ (define-read-only (verify-signature (signature (buff 65)) (signer principal) - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (balance-1 uint) (balance-2 uint) (nonce uint) @@ -937,16 +873,12 @@ (hashed-secret (optional (buff 32))) (valid-after (optional uint)) ) - (let ((hash (unwrap! (make-structured-data-hash - pipe-key - balance-1 - balance-2 - nonce - action - actor - hashed-secret - valid-after - ) false))) + (let ((hash (unwrap! + (make-structured-data-hash pipe-key balance-1 balance-2 nonce action actor + hashed-secret valid-after + ) + false + ))) (verify-hash-signature hash signature signer actor) ) ) @@ -963,7 +895,11 @@ (signer-1 principal) (signature-2 (buff 65)) (signer-2 principal) - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (balance-1 uint) (balance-2 uint) (nonce uint) @@ -973,20 +909,21 @@ (valid-after (optional uint)) ) (let ( - (hashed-secret (match secret s (some (sha256 s)) none)) - (hash (try! (make-structured-data-hash - pipe-key - balance-1 - balance-2 - nonce - action - actor - hashed-secret - valid-after - )))) + (hashed-secret (match secret + s (some (sha256 s)) + none + )) + (hash (try! (make-structured-data-hash pipe-key balance-1 balance-2 nonce action actor + hashed-secret valid-after + ))) + ) (try! (balance-check pipe-key balance-1 balance-2 valid-after)) - (asserts! (verify-hash-signature hash signature-1 signer-1 actor) ERR_INVALID_SENDER_SIGNATURE) - (asserts! (verify-hash-signature hash signature-2 signer-2 actor) ERR_INVALID_OTHER_SIGNATURE) + (asserts! (verify-hash-signature hash signature-1 signer-1 actor) + ERR_INVALID_SENDER_SIGNATURE + ) + (asserts! (verify-hash-signature hash signature-2 signer-2 actor) + ERR_INVALID_OTHER_SIGNATURE + ) (ok true) ) ) @@ -997,8 +934,7 @@ ;;; Given an optional trait, return an optional principal for the trait. (define-private (contract-of-optional (trait (optional ))) (match trait - t - (some (contract-of t)) + t (some (contract-of t)) none ) ) @@ -1006,15 +942,26 @@ ;;; Given two principals, return the key for the pipe between these two principals. ;;; The key is a map with two keys: principal-1 and principal-2, where principal-1 is the principal ;;; with the lower consensus representation. -(define-private (get-pipe-key (token (optional principal)) (principal-1 principal) (principal-2 principal)) - (let - ( +(define-private (get-pipe-key + (token (optional principal)) + (principal-1 principal) + (principal-2 principal) + ) + (let ( (p1 (unwrap! (to-consensus-buff? principal-1) ERR_INVALID_PRINCIPAL)) (p2 (unwrap! (to-consensus-buff? principal-2) ERR_INVALID_PRINCIPAL)) ) (ok (if (< p1 p2) - { token: token, principal-1: principal-1, principal-2: principal-2 } - { token: token, principal-1: principal-2, principal-2: principal-1 } + { + token: token, + principal-1: principal-1, + principal-2: principal-2, + } + { + token: token, + principal-1: principal-2, + principal-2: principal-1, + } )) ) ) @@ -1027,15 +974,25 @@ ;;; - `ERR_ALREADY_PENDING` if there is already a pending deposit for the ;;; sender (define-private (increase-sender-balance - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (pipe { balance-1: uint, balance-2: uint, - pending-1: (optional { amount: uint, burn-height: uint }), - pending-2: (optional { amount: uint, burn-height: uint }), + pending-1: (optional { + amount: uint, + burn-height: uint, + }), + pending-2: (optional { + amount: uint, + burn-height: uint, + }), expires-at: uint, nonce: uint, - closer: (optional principal) + closer: (optional principal), }) (token (optional )) (amount uint) @@ -1044,34 +1001,33 @@ ;; If there are outstanding deposits that can be settled, settle them. (settled-pipe (settle-pending pipe-key pipe)) ) - (match token - t - (unwrap! (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) ERR_DEPOSIT_FAILED) - (unwrap! (stx-transfer? amount tx-sender (as-contract tx-sender)) ERR_DEPOSIT_FAILED) + t (unwrap! + (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) + ERR_DEPOSIT_FAILED + ) + (unwrap! (stx-transfer? amount tx-sender (as-contract tx-sender)) + ERR_DEPOSIT_FAILED + ) ) - (ok - (if (is-eq tx-sender (get principal-1 pipe-key)) - (begin - (asserts! (is-none (get pending-1 settled-pipe)) ERR_ALREADY_PENDING) - (merge settled-pipe { - pending-1: (some { - amount: amount, - burn-height: (+ burn-block-height CONFIRMATION_DEPTH) - }) - }) + (ok (if (is-eq tx-sender (get principal-1 pipe-key)) + (begin + (asserts! (is-none (get pending-1 settled-pipe)) ERR_ALREADY_PENDING) + (merge settled-pipe { pending-1: (some { + amount: amount, + burn-height: (+ burn-block-height CONFIRMATION_DEPTH), + }) } ) - (begin - (asserts! (is-none (get pending-2 settled-pipe)) ERR_ALREADY_PENDING) - (merge settled-pipe { - pending-2: (some { - amount: amount, - burn-height: (+ burn-block-height CONFIRMATION_DEPTH) - }) - }) + ) + (begin + (asserts! (is-none (get pending-2 settled-pipe)) ERR_ALREADY_PENDING) + (merge settled-pipe { pending-2: (some { + amount: amount, + burn-height: (+ burn-block-height CONFIRMATION_DEPTH), + }) } ) ) - ) + )) ) ) @@ -1085,8 +1041,7 @@ (let ((sender tx-sender)) (unwrap! (match token - t - (as-contract (contract-call? t transfer amount tx-sender sender none)) + t (as-contract (contract-call? t transfer amount tx-sender sender none)) (as-contract (stx-transfer? amount tx-sender sender)) ) ERR_WITHDRAWAL_FAILED @@ -1110,16 +1065,21 @@ (secret (optional (buff 32))) (valid-after (optional uint)) ) - (let - ( + (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) for with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (expires-at (get expires-at pipe)) (pipe-nonce (get nonce pipe)) (closer (unwrap! (get closer pipe) ERR_NO_CLOSE_IN_PROGRESS)) (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq for principal-1) my-balance their-balance)) - (balance-2 (if (is-eq for principal-1) their-balance my-balance)) + (balance-1 (if (is-eq for principal-1) + my-balance + their-balance + )) + (balance-2 (if (is-eq for principal-1) + their-balance + my-balance + )) ) ;; Exit early if this is an attempt to self-dispute @@ -1140,42 +1100,22 @@ ;; If the total balance of the pipe is not equal to the sum of the ;; balances provided, the pipe close is invalid. (asserts! - (is-eq - (+ my-balance their-balance) + (is-eq (+ my-balance their-balance) (+ (get balance-1 pipe) (get balance-2 pipe)) ) ERR_INVALID_TOTAL_BALANCE ) - - (let - ( - (updated-pipe { - balance-1: balance-1, - balance-2: balance-2, - expires-at: MAX_HEIGHT, - nonce: nonce, - closer: none - }) - ) - + (let ((updated-pipe { + balance-1: balance-1, + balance-2: balance-2, + expires-at: MAX_HEIGHT, + nonce: nonce, + closer: none, + })) ;; Verify the signatures of the two parties. - (try! - (verify-signatures - my-signature - for - their-signature - with - pipe-key - balance-1 - balance-2 - nonce - action - actor - secret - valid-after - ) - ) - + (try! (verify-signatures my-signature for their-signature with pipe-key balance-1 + balance-2 nonce action actor secret valid-after + )) ;; Reset the pipe in the map. (reset-pipe pipe-key nonce) @@ -1196,15 +1136,25 @@ ;;; Check if the balance of `account` in the pipe is greater than 0. (define-private (is-funded (account principal) - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (pipe { balance-1: uint, balance-2: uint, - pending-1: (optional { amount: uint, burn-height: uint }), - pending-2: (optional { amount: uint, burn-height: uint }), + pending-1: (optional { + amount: uint, + burn-height: uint, + }), + pending-2: (optional { + amount: uint, + burn-height: uint, + }), expires-at: uint, nonce: uint, - closer: (optional principal) + closer: (optional principal), }) ) (or @@ -1230,14 +1180,23 @@ ) ;;; Transfer `amount` of `token` to `addr`. Handles both SIP-010 tokens and STX. -(define-private (transfer (token (optional )) (addr principal) (amount uint)) +(define-private (transfer + (token (optional )) + (addr principal) + (amount uint) + ) (if (is-eq amount u0) ;; Don't try to transfer 0, this will cause an error (ok (is-some token)) (begin (match token - t (unwrap! (as-contract (contract-call? t transfer amount tx-sender addr none)) ERR_WITHDRAWAL_FAILED) - (unwrap! (as-contract (stx-transfer? amount tx-sender addr)) ERR_WITHDRAWAL_FAILED) + t (unwrap! + (as-contract (contract-call? t transfer amount tx-sender addr none)) + ERR_WITHDRAWAL_FAILED + ) + (unwrap! (as-contract (stx-transfer? amount tx-sender addr)) + ERR_WITHDRAWAL_FAILED + ) ) (ok (is-some token)) ) @@ -1246,22 +1205,22 @@ ;;; Reset the pipe so that it is closed but retains the last nonce. (define-private (reset-pipe - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (nonce uint) ) - (map-set - pipes - pipe-key - { - balance-1: u0, - balance-2: u0, - pending-1: none, - pending-2: none, - expires-at: MAX_HEIGHT, - nonce: nonce, - closer: none - } - ) + (map-set pipes pipe-key { + balance-1: u0, + balance-2: u0, + pending-1: none, + pending-2: none, + expires-at: MAX_HEIGHT, + nonce: nonce, + closer: none, + }) ) ;;; Verify a signature for a hash. @@ -1273,12 +1232,17 @@ (actor principal) ) (or - (is-eq (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) (ok signer)) + (is-eq (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) + (ok signer) + ) ;; If the signer is not the actor, then the agent can sign for the signer. (and (not (is-eq signer actor)) (match (map-get? agents signer) - agent (is-eq (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) (ok agent)) + agent (is-eq + (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) + (ok agent) + ) false ) ) @@ -1292,7 +1256,9 @@ (asserts! (var-get initialized) ERR_NOT_INITIALIZED) ;; Verify that this is the supported token - (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) ERR_UNAPPROVED_TOKEN) + (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) + ERR_UNAPPROVED_TOKEN + ) (ok true) ) @@ -1301,15 +1267,25 @@ ;;; Settle the pending deposit(s) for a pipe. ;;; Returns the updated pipe with deposits settled if possible. (define-private (settle-pending - (pipe-key { token: (optional principal), principal-1: principal, principal-2: principal }) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) (pipe { balance-1: uint, balance-2: uint, - pending-1: (optional { amount: uint, burn-height: uint }), - pending-2: (optional { amount: uint, burn-height: uint }), + pending-1: (optional { + amount: uint, + burn-height: uint, + }), + pending-2: (optional { + amount: uint, + burn-height: uint, + }), expires-at: uint, nonce: uint, - closer: (optional principal) + closer: (optional principal), }) ) (let ( @@ -1317,21 +1293,33 @@ pending (if (>= burn-block-height (get burn-height pending)) { balance-1: (+ (get balance-1 pipe) (get amount pending)), - pending-1: none + pending-1: none, + } + { + balance-1: (get balance-1 pipe), + pending-1: (some pending), } - { balance-1: (get balance-1 pipe), pending-1: (some pending) } ) - { balance-1: (get balance-1 pipe), pending-1: none } + { + balance-1: (get balance-1 pipe), + pending-1: none, + } )) (settle-2 (match (get pending-2 pipe) pending (if (>= burn-block-height (get burn-height pending)) { balance-2: (+ (get balance-2 pipe) (get amount pending)), - pending-2: none + pending-2: none, + } + { + balance-2: (get balance-2 pipe), + pending-2: (some pending), } - { balance-2: (get balance-2 pipe), pending-2: (some pending) } ) - { balance-2: (get balance-2 pipe), pending-2: none } + { + balance-2: (get balance-2 pipe), + pending-2: none, + } )) (updated-pipe (merge (merge pipe settle-1) settle-2)) ) @@ -1352,8 +1340,7 @@ (balance-2 uint) (at-height-opt (optional uint)) ) - (let - ( + (let ( (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (at-height (default-to burn-block-height at-height-opt)) (pipe-1 (get balance-1 pipe)) @@ -1373,22 +1360,19 @@ ;; Ensure that these balances do not require spending the pending deposits. (asserts! - (<= - balance-1 - (+ - (get confirmed pipe-balances-1) - (get pending pipe-balances-1) - (get confirmed pipe-balances-2))) - ERR_INVALID_BALANCES) - (asserts! - (<= - balance-2 - (+ + (<= balance-1 + (+ (get confirmed pipe-balances-1) (get pending pipe-balances-1) (get confirmed pipe-balances-2) - (get pending pipe-balances-2) - (get confirmed pipe-balances-1))) - ERR_INVALID_BALANCES) - + )) + ERR_INVALID_BALANCES + ) + (asserts! + (<= balance-2 + (+ (get confirmed pipe-balances-2) (get pending pipe-balances-2) + (get confirmed pipe-balances-1) + )) + ERR_INVALID_BALANCES + ) (ok true) ) ) @@ -1398,14 +1382,26 @@ ;;; Returns a tuple with the confirmed balance and the pending balance. (define-private (calculate-balances (confirmed uint) - (maybe-pending (optional { amount: uint, burn-height: uint })) + (maybe-pending (optional { + amount: uint, + burn-height: uint, + })) (at-height uint) ) (match maybe-pending pending (if (>= at-height (get burn-height pending)) - { confirmed: (+ confirmed (get amount pending)), pending: u0 } - { confirmed: confirmed, pending: (get amount pending) } + { + confirmed: (+ confirmed (get amount pending)), + pending: u0, + } + { + confirmed: confirmed, + pending: (get amount pending), + } ) - { confirmed: confirmed, pending: u0 } + { + confirmed: confirmed, + pending: u0, + } ) ) From 25e71b97ac6aa2d9a1973051cb97d18fc96d2846 Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 10 May 2025 10:27:12 -0400 Subject: [PATCH 03/78] chore: format the other contracts --- contracts/reservoir.clar | 27 ++--- contracts/stackflow-token.clar | 186 +++++++++++++++++---------------- contracts/test-token.clar | 25 +++-- 3 files changed, 128 insertions(+), 110 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 15de24f..6f720f8 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -76,8 +76,7 @@ (let ((borrower tx-sender)) (unwrap! (match token - t - (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) + t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) (stx-transfer? amount tx-sender (as-contract tx-sender)) ) ERR_BORROW_FEE_PAYMENT_FAILED @@ -94,13 +93,15 @@ ;;; - `(ok true)` on success ;;; - `ERR_UNAUTHORIZED` if the caller is not the operator ;;; - `ERR_FUNDING_FAILED` if the funding failed -(define-public (add-liquidity (token (optional )) (amount uint)) +(define-public (add-liquidity + (token (optional )) + (amount uint) + ) (begin (asserts! (is-eq tx-sender OPERATOR) ERR_UNAUTHORIZED) (unwrap! (match token - t - (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) + t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) (stx-transfer? amount tx-sender (as-contract tx-sender)) ) ERR_FUNDING_FAILED @@ -114,17 +115,17 @@ ;;; - `(ok true)` on success ;;; - `ERR_UNAUTHORIZED` if the caller is not the operator ;;; - `ERR_TRANSFER_FAILED` if the transfer failed -(define-public (remove-liquidity (token (optional )) (amount uint)) +(define-public (remove-liquidity + (token (optional )) + (amount uint) + ) (begin (asserts! (is-eq tx-sender OPERATOR) ERR_UNAUTHORIZED) (unwrap! - (as-contract - (match token - t - (contract-call? t transfer amount tx-sender OPERATOR none) - (stx-transfer? amount tx-sender OPERATOR) - ) - ) + (as-contract (match token + t (contract-call? t transfer amount tx-sender OPERATOR none) + (stx-transfer? amount tx-sender OPERATOR) + )) ERR_TRANSFER_FAILED ) (ok true) diff --git a/contracts/stackflow-token.clar b/contracts/stackflow-token.clar index 8362d67..8daca19 100644 --- a/contracts/stackflow-token.clar +++ b/contracts/stackflow-token.clar @@ -28,100 +28,110 @@ (use-trait sip-010 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) -(define-trait stackflow-token - ( - (fund-pipe - ( - (optional ) ;; token - uint ;; amount - principal ;; with - uint ;; nonce - ) - (response { token: (optional principal), principal-1: principal, principal-2: principal } uint) +(define-trait stackflow-token ( + (fund-pipe + ( + (optional ) ;; token + uint ;; amount + principal ;; with + uint ;; nonce ) - (close-pipe - ( - (optional ) ;; token - principal ;; with - uint ;; my-balance - uint ;; their-balance - (buff 65) ;; my-signature - (buff 65) ;; their-signature - uint ;; nonce - ) - (response bool uint) + (response { + token: (optional principal), + principal-1: principal, + principal-2: principal, + } uint) + ) + (close-pipe + ( + (optional ) ;; token + principal ;; with + uint ;; my-balance + uint ;; their-balance + (buff 65) ;; my-signature + (buff 65) ;; their-signature + uint ;; nonce ) - (force-cancel - ( - (optional ) ;; token - principal ;; with - ) - (response uint uint) + (response bool uint) + ) + (force-cancel + ( + (optional ) ;; token + principal ;; with ) - (force-close - ( - (optional ) ;; token - principal ;; with - uint ;; my-balance - uint ;; their-balance - (buff 65) ;; my-signature - (buff 65) ;; their-signature - uint ;; nonce - uint ;; action - principal ;; actor - (optional (buff 32)) ;; secret - (optional uint) ;; valid-after - ) - (response uint uint) + (response uint uint) + ) + (force-close + ( + (optional ) ;; token + principal ;; with + uint ;; my-balance + uint ;; their-balance + (buff 65) ;; my-signature + (buff 65) ;; their-signature + uint ;; nonce + uint ;; action + principal ;; actor + (optional (buff 32)) ;; secret + (optional uint) ;; valid-after ) - (dispute-closure - ( - (optional ) ;; token - principal ;; with - uint ;; my-balance - uint ;; their-balance - (buff 65) ;; my-signature - (buff 65) ;; their-signature - uint ;; nonce - uint ;; action - principal ;; actor - (optional (buff 32)) ;; secret - (optional uint) ;; valid-after - ) - (response bool uint) + (response uint uint) + ) + (dispute-closure + ( + (optional ) ;; token + principal ;; with + uint ;; my-balance + uint ;; their-balance + (buff 65) ;; my-signature + (buff 65) ;; their-signature + uint ;; nonce + uint ;; action + principal ;; actor + (optional (buff 32)) ;; secret + (optional uint) ;; valid-after ) - (finalize - ( - (optional ) ;; token - principal ;; with - ) - (response bool uint) + (response bool uint) + ) + (finalize + ( + (optional ) ;; token + principal ;; with ) - (deposit - ( - uint ;; amount - (optional ) ;; token - principal ;; with - uint ;; my-balance - uint ;; their-balance - (buff 65) ;; my-signature - (buff 65) ;; their-signature - uint ;; nonce - ) - (response { token: (optional principal), principal-1: principal, principal-2: principal } uint) + (response bool uint) + ) + (deposit + ( + uint ;; amount + (optional ) ;; token + principal ;; with + uint ;; my-balance + uint ;; their-balance + (buff 65) ;; my-signature + (buff 65) ;; their-signature + uint ;; nonce ) - (withdraw - ( - uint ;; amount - (optional ) ;; token - principal ;; with - uint ;; my-balance - uint ;; their-balance - (buff 65) ;; my-signature - (buff 65) ;; their-signature - uint ;; nonce - ) - (response { token: (optional principal), principal-1: principal, principal-2: principal } uint) + (response { + token: (optional principal), + principal-1: principal, + principal-2: principal, + } uint) + ) + (withdraw + ( + uint ;; amount + (optional ) ;; token + principal ;; with + uint ;; my-balance + uint ;; their-balance + (buff 65) ;; my-signature + (buff 65) ;; their-signature + uint ;; nonce ) + (response { + token: (optional principal), + principal-1: principal, + principal-2: principal, + } uint) ) -) \ No newline at end of file +)) diff --git a/contracts/test-token.clar b/contracts/test-token.clar index 295886a..64d58bb 100644 --- a/contracts/test-token.clar +++ b/contracts/test-token.clar @@ -15,7 +15,6 @@ (define-constant TOKEN_SYMBOL "TEST") (define-constant TOKEN_DECIMALS u6) ;; 6 units displayed past decimal, e.g. 1.000_000 = 1 token - ;; SIP-010 function: Get the token balance of a specified principal (define-read-only (get-balance (who principal)) (ok (ft-get-balance test-coin who)) @@ -48,7 +47,10 @@ ;; Mint new tokens and send them to a recipient. ;; Only the contract deployer can perform this operation. -(define-public (mint (amount uint) (recipient principal)) +(define-public (mint + (amount uint) + (recipient principal) + ) (begin (asserts! (is-eq tx-sender CONTRACT_OWNER) ERR_OWNER_ONLY) (ft-mint? test-coin amount recipient) @@ -58,16 +60,21 @@ ;; SIP-010 function: Transfers tokens to a recipient ;; Sender must be the same as the caller to prevent principals from transferring tokens they do not own. (define-public (transfer - (amount uint) - (sender principal) - (recipient principal) - (memo (optional (buff 34))) -) + (amount uint) + (sender principal) + (recipient principal) + (memo (optional (buff 34))) + ) (begin ;; #[filter(amount, recipient)] - (asserts! (or (is-eq tx-sender sender) (is-eq contract-caller sender)) ERR_NOT_TOKEN_OWNER) + (asserts! (or (is-eq tx-sender sender) (is-eq contract-caller sender)) + ERR_NOT_TOKEN_OWNER + ) (try! (ft-transfer? test-coin amount sender recipient)) - (match memo to-print (print to-print) 0x) + (match memo + to-print (print to-print) + 0x + ) (ok true) ) ) From 648c37073ded5c01024370cbf98d86eeb7138d1c Mon Sep 17 00:00:00 2001 From: obycode Date: Mon, 12 May 2025 08:18:01 -0400 Subject: [PATCH 04/78] feat: continue work on Reservoir --- contracts/reservoir.clar | 98 +++++++- contracts/stackflow-token.clar | 10 + contracts/stackflow.clar | 15 +- tests/reservoir.test.ts | 406 +++++++++++++++++++++++++++++++++ tests/stackflow.test.ts | 352 +++++----------------------- tests/utils.ts | 258 +++++++++++++++++++++ 6 files changed, 831 insertions(+), 308 deletions(-) create mode 100644 tests/reservoir.test.ts create mode 100644 tests/utils.ts diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 6f720f8..9a2b0ef 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -23,7 +23,8 @@ ;; SOFTWARE. (use-trait sip-010 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) -;; (impl-trait 'SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token) +(use-trait stackflow-token .stackflow-token.stackflow-token) +;; (use-trait stackflow-token 'SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token) (define-constant OPERATOR tx-sender) (define-constant RESERVOIR (as-contract tx-sender)) @@ -33,6 +34,47 @@ (define-constant ERR_UNAUTHORIZED (err u201)) (define-constant ERR_FUNDING_FAILED (err u202)) (define-constant ERR_TRANSFER_FAILED (err u203)) +(define-constant ERR_INVALID_FEE (err u204)) +(define-constant ERR_ALREADY_INITIALIZED (err u205)) +(define-constant ERR_NOT_INITIALIZED (err u206)) + +;;; Has this contract been initialized? +(define-data-var initialized bool false) + +;; Current borrow rate in basis points (1 = 0.01%) +;; For example, 1000 = 10% +(define-data-var borrow-rate uint u0) + +;;; The token supported by this instance of the Reservoir contract. +;;; If `none`, only STX is supported. +(define-data-var supported-token (optional principal) none) + +;;; Track the open taps +;; The key is the principal and the value is the height at which it was opened. +(define-map taps principal uint) + +(define-public (init + (token (optional )) + (stackflow ) + (initial-borrow-rate uint) + ) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (asserts! (not (var-get initialized)) ERR_ALREADY_INITIALIZED) + + ;; Set the token for this instance of the Reservoir contract. + (var-set supported-token (contract-of-optional token)) + + ;; Authorize the operator as a StackFlow agent for this contract. + (try! (as-contract (contract-call? stackflow register-agent OPERATOR))) + + ;; Set the initial borrow rate. + (var-set borrow-rate initial-borrow-rate) + + ;; Initialize the contract. + (ok (var-set initialized true)) + ) +) ;;; Deposit `amount` funds into an unfunded tap for FT `token` (`none` ;;; indicates STX). Create the tap if one does not already exist. @@ -49,16 +91,19 @@ (define-public (fund-tap (token (optional )) (amount uint) - (with principal) (nonce uint) ) - (contract-call? .stackflow fund-pipe token amount RESERVOIR nonce) + (begin + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (contract-call? .stackflow fund-pipe token amount RESERVOIR nonce) + ) ) ;;; Borrow `amount` from the reservoir to add receiving capacity to the ;;; caller's tap. The caller pays a fee of `fee` to the reservoir. The caller -;;; provides their own signature as well as a signature that they obtained -;;; from the reservoir, confirming the resulting balances in the tap. +;;; provides their own signature for the deposit, as well as a signature that +;;; they obtained from the reservoir, confirming the resulting balances in the +;;; tap. ;;; Returns: ;;; -`(ok pipe-key)` on success ;;; - `ERR_BORROW_FEE_PAYMENT_FAILED` if the fee payment failed @@ -73,11 +118,16 @@ (reservoir-signature (buff 65)) (nonce uint) ) - (let ((borrower tx-sender)) + (let ( + (borrower tx-sender) + (expected-fee (get-borrow-fee amount)) + ) + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (asserts! (>= fee expected-fee) ERR_INVALID_FEE) (unwrap! (match token - t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) - (stx-transfer? amount tx-sender (as-contract tx-sender)) + t (contract-call? t transfer fee tx-sender RESERVOIR none) + (stx-transfer? fee tx-sender RESERVOIR) ) ERR_BORROW_FEE_PAYMENT_FAILED ) @@ -87,6 +137,24 @@ ) ) +;; Set the borrow rate for the contract (in basis points). +;; Returns: +;; - `(ok true)` on success +;; - `ERR_UNAUTHORIZED` if the caller is not the operator +(define-public (set-borrow-rate (new-rate uint)) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (ok (var-set borrow-rate new-rate)) + ) +) + +;; Calculate the fee for borrowing a given amount. +;; Returns the fee amount in the smallest unit of the token. +(define-read-only (get-borrow-fee (amount uint)) + (/ (* amount (var-get borrow-rate)) u10000) +) + ;;; As the operator, add `amount` of STX or FT `token` to the reservoir for ;;; borrowing. ;;; Returns: @@ -98,7 +166,8 @@ (amount uint) ) (begin - (asserts! (is-eq tx-sender OPERATOR) ERR_UNAUTHORIZED) + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) (unwrap! (match token t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) @@ -120,7 +189,8 @@ (amount uint) ) (begin - (asserts! (is-eq tx-sender OPERATOR) ERR_UNAUTHORIZED) + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) (unwrap! (as-contract (match token t (contract-call? t transfer amount tx-sender OPERATOR none) @@ -131,3 +201,11 @@ (ok true) ) ) + +;;; Given an optional trait, return an optional principal for the trait. +(define-private (contract-of-optional (trait (optional ))) + (match trait + t (some (contract-of t)) + none + ) +) diff --git a/contracts/stackflow-token.clar b/contracts/stackflow-token.clar index 8daca19..836671f 100644 --- a/contracts/stackflow-token.clar +++ b/contracts/stackflow-token.clar @@ -29,6 +29,16 @@ (use-trait sip-010 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) (define-trait stackflow-token ( + (register-agent + ( + principal ;; agent + ) + (response bool uint) + ) + (deregister-agent + () + (response bool uint) + ) (fund-pipe ( (optional ) ;; token diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 691d22b..30a3665 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -1235,16 +1235,13 @@ (is-eq (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) (ok signer) ) - ;; If the signer is not the actor, then the agent can sign for the signer. - (and - (not (is-eq signer actor)) - (match (map-get? agents signer) - agent (is-eq - (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) - (ok agent) - ) - false + ;; Check if the signer is an agent of the actor + (match (map-get? agents signer) + agent (is-eq + (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) + (ok agent) ) + false ) ) ) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts new file mode 100644 index 0000000..e335bf3 --- /dev/null +++ b/tests/reservoir.test.ts @@ -0,0 +1,406 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + Cl, + ClarityType, + ResponseOkCV, +} from "@stacks/transactions"; +import { + deployer, + address1, + address1PK, + address2PK, + reservoirContract, + StackflowError, + generateDepositSignature, + ReservoirError, + stackflowContract, + deployerPK, + MAX_HEIGHT, + CONFIRMATION_DEPTH, +} from "./utils"; + +describe("reservoir", () => { + beforeEach(() => { + // Initialize stackflow contract for STX before each test + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + // Authorize the deployer as an agent for the reservoir contract + simnet.callPublicFn( + "reservoir", + "init", + [Cl.none(), Cl.principal(stackflowContract), Cl.uint(0)], + deployer + ); + }); + + describe("borrow rate", () => { + it("operator can set borrow rate", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], // 10% + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + }); + + it("non-operator cannot set borrow rate", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + + it("calculates correct borrow fee", () => { + // Set rate to 10% + simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + + // Calculate fee for 1000 tokens + const { result } = simnet.callReadOnlyFn( + "reservoir", + "get-borrow-fee", + [Cl.uint(1000)], + deployer + ); + expect(result).toBeUint(100); // 10% of 1000 = 100 + }); + + it("rejects borrow with incorrect fee", () => { + // Set rate to 10% + simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + + // Fund initial tap + simnet.callPublicFn( + "reservoir", + "fund-tap", + [Cl.none(), Cl.uint(1000000), Cl.uint(0)], + address1 + ); + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000, + 1000000, + 1, + address1 + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 1000000, + 1000, + 1, + address1 + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.uint(1000), // amount + Cl.uint(50), // incorrect fee (should be 100) + Cl.none(), // token + Cl.uint(1000), // my balance + Cl.uint(1000000), // reservoir balance + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), // nonce + ], + address1 + ); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidFee)); + }); + }); + + describe("liquidity management", () => { + it("operator can add STX liquidity", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000)], + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + + // Verify reservoir balance + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000n); + }); + + it("operator can remove STX liquidity", () => { + // First add liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000)], + deployer + ); + + // Then remove some + const { result } = simnet.callPublicFn( + "reservoir", + "remove-liquidity", + [Cl.none(), Cl.uint(500000)], + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + + // Verify balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(500000n); + }); + + it("non-operator cannot add liquidity", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + + it("non-operator cannot remove liquidity", () => { + // First add liquidity as operator + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000)], + deployer + ); + + // Try to remove as non-operator + const { result } = simnet.callPublicFn( + "reservoir", + "remove-liquidity", + [Cl.none(), Cl.uint(500000)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + }); + + describe("tap management", () => { + it("can fund new tap", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "fund-tap", + [Cl.none(), Cl.uint(1000000), Cl.uint(0)], + address1 + ); + expect(result).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(reservoirContract), + }) + ); + + // Verify tap balance + const stxBalances = simnet.getAssetsMap().get("STX")!; + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(1000000n); + }); + + it("can borrow liquidity with correct fee", () => { + // Set rate to 10% + simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + + // Add liquidity to reservoir + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(2000000)], + deployer + ); + + // Fund initial tap + const { result } = simnet.callPublicFn( + "reservoir", + "fund-tap", + [Cl.none(), Cl.uint(1000000), Cl.uint(0)], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + const pipeKey = (result as ResponseOkCV).value; + + const amount = 1000000; + const fee = amount * 0.1; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 1000000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 1000000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(1000000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(reservoirContract), + }) + ); + + // Verify balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000n + BigInt(fee)); + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(2000000n); + + // Verify the pipe + const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); + expect(pipe).toBeSome( + Cl.tuple({ + "balance-1": Cl.uint(0), + "balance-2": Cl.uint(0), + "expires-at": Cl.uint(MAX_HEIGHT), + nonce: Cl.uint(1), + closer: Cl.none(), + "pending-1": Cl.some( + Cl.tuple({ + amount: Cl.uint(1000000), + "burn-height": Cl.uint( + simnet.burnBlockHeight + CONFIRMATION_DEPTH + ), + }) + ), + "pending-2": Cl.some( + Cl.tuple({ + amount: Cl.uint(1000000), + "burn-height": Cl.uint( + simnet.burnBlockHeight + CONFIRMATION_DEPTH + ), + }) + ), + }) + ); + }); + + it("cannot borrow with insufficient reservoir liquidity", () => { + // Set rate to 10% + simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + + // Add small amount of liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(100000)], + deployer + ); + + // Fund initial tap + simnet.callPublicFn( + "reservoir", + "fund-tap", + [Cl.none(), Cl.uint(50000), Cl.uint(0)], + address1 + ); + + // Try to borrow more than available + const amount = 1000000; + const fee = 100000; + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 50000, + 1000000, + 1, + address1 + ); + + const reservoirSignature = generateDepositSignature( + address2PK, + null, + reservoirContract, + address1, + 1000000, + 50000, + 1, + address1 + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(50000), + Cl.uint(1000000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(result).toBeErr(Cl.uint(StackflowError.DepositFailed)); + }); + }); +}); diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 8173a1c..526ffd8 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -1,251 +1,25 @@ -import { - Cl, - ClarityType, - ClarityValue, - createStacksPrivateKey, - cvToString, - ResponseOkCV, - serializeCV, - signWithKey, - StacksPrivateKey, -} from "@stacks/transactions"; +import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; import { describe, expect, it } from "vitest"; -import { createHash } from "crypto"; - -const accounts = simnet.getAccounts(); -const deployer = accounts.get("deployer")!; -const address1 = accounts.get("wallet_1")!; -const address2 = accounts.get("wallet_2")!; -const address3 = accounts.get("wallet_3")!; -const stackflowContract = `${deployer}.stackflow`; - -const address1PK = createStacksPrivateKey( - "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801" -); -const address2PK = createStacksPrivateKey( - "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101" -); -const address3PK = createStacksPrivateKey( - "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901" -); - -const WAITING_PERIOD = 144; -const MAX_HEIGHT = 340282366920938463463374607431768211455n; - -enum PipeAction { - Close = 0, - Transfer = 1, - Deposit = 2, - Withdraw = 3, -} - -enum TxError { - DepositFailed = 100, - NoSuchPipe = 101, - InvalidPrincipal = 102, - InvalidSenderSignature = 103, - InvalidOtherSignature = 104, - ConsensusBuff = 105, - Unauthorized = 106, - MaxAllowed = 107, - InvalidTotalBalance = 108, - WithdrawalFailed = 109, - PipeExpired = 110, - NonceTooLow = 111, - CloseInProgress = 112, - NoCloseInProgress = 113, - SelfDispute = 114, - AlreadyFunded = 115, - InvalidWithdrawal = 116, - UnapprovedToken = 117, - NotExpired = 118, - NotInitialized = 119, - AlreadyInitialized = 120, - NotValidYet = 121, - AlreadyPending = 122, - Pending = 123, - InvalidBalances = 124, -} - -const CONFIRMATION_DEPTH = 6; - -const structuredDataPrefix = Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); - -const chainIds = { - mainnet: 1, - testnet: 2147483648, -}; - -function sha256(data: Buffer): Buffer { - return createHash("sha256").update(data).digest(); -} - -function structuredDataHash(structuredData: ClarityValue): Buffer { - return sha256(Buffer.from(serializeCV(structuredData))); -} - -const domainHash = structuredDataHash( - Cl.tuple({ - name: Cl.stringAscii("StackFlow"), - version: Cl.stringAscii("0.6.0"), - "chain-id": Cl.uint(chainIds.testnet), - }) -); - -function structuredDataHashWithPrefix(structuredData: ClarityValue): Buffer { - const messageHash = structuredDataHash(structuredData); - return sha256(Buffer.concat([structuredDataPrefix, domainHash, messageHash])); -} - -function signStructuredData( - privateKey: StacksPrivateKey, - structuredData: ClarityValue -): Buffer { - const hash = structuredDataHashWithPrefix(structuredData); - const data = signWithKey(privateKey, hash.toString("hex")).data; - return Buffer.from(data.slice(2) + data.slice(0, 2), "hex"); -} - -function generatePipeSignature( - privateKey: StacksPrivateKey, - token: [string, string] | null, - myPrincipal: string, - theirPrincipal: string, - myBalance: number, - theirBalance: number, - nonce: number, - action: PipeAction, - actor: string, - secret: string | null = null, - valid_after: number | null = null -): Buffer { - const meFirst = myPrincipal < theirPrincipal; - const principal1 = meFirst ? myPrincipal : theirPrincipal; - const principal2 = meFirst ? theirPrincipal : myPrincipal; - const balance1 = meFirst ? myBalance : theirBalance; - const balance2 = meFirst ? theirBalance : myBalance; - - const tokenCV = - token === null - ? Cl.none() - : Cl.some(Cl.contractPrincipal(token[0], token[1])); - const secretCV = - secret === null - ? Cl.none() - : Cl.some(Cl.buffer(sha256(Buffer.from(secret, "hex")))); - const validAfterCV = - valid_after === null ? Cl.none() : Cl.some(Cl.uint(valid_after)); - - const data = Cl.tuple({ - token: tokenCV, - "principal-1": Cl.principal(principal1), - "principal-2": Cl.principal(principal2), - "balance-1": Cl.uint(balance1), - "balance-2": Cl.uint(balance2), - nonce: Cl.uint(nonce), - action: Cl.uint(action), - actor: Cl.principal(actor), - "hashed-secret": secretCV, - "valid-after": validAfterCV, - }); - return signStructuredData(privateKey, data); -} - -function generateClosePipeSignature( - privateKey: StacksPrivateKey, - token: [string, string] | null, - myPrincipal: string, - theirPrincipal: string, - myBalance: number, - theirBalance: number, - nonce: number, - actor: string -): Buffer { - return generatePipeSignature( - privateKey, - token, - myPrincipal, - theirPrincipal, - myBalance, - theirBalance, - nonce, - PipeAction.Close, - actor - ); -} - -function generateTransferSignature( - privateKey: StacksPrivateKey, - token: [string, string] | null, - myPrincipal: string, - theirPrincipal: string, - myBalance: number, - theirBalance: number, - nonce: number, - actor: string, - secret: string | null = null, - valid_after: number | null = null -): Buffer { - return generatePipeSignature( - privateKey, - token, - myPrincipal, - theirPrincipal, - myBalance, - theirBalance, - nonce, - PipeAction.Transfer, - actor, - secret, - valid_after - ); -} - -function generateDepositSignature( - privateKey: StacksPrivateKey, - token: [string, string] | null, - myPrincipal: string, - theirPrincipal: string, - myBalance: number, - theirBalance: number, - nonce: number, - actor: string -): Buffer { - return generatePipeSignature( - privateKey, - token, - myPrincipal, - theirPrincipal, - myBalance, - theirBalance, - nonce, - PipeAction.Deposit, - actor - ); -} - -function generateWithdrawSignature( - privateKey: StacksPrivateKey, - token: [string, string] | null, - myPrincipal: string, - theirPrincipal: string, - myBalance: number, - theirBalance: number, - nonce: number, - actor: string -): Buffer { - return generatePipeSignature( - privateKey, - token, - myPrincipal, - theirPrincipal, - myBalance, - theirBalance, - nonce, - PipeAction.Withdraw, - actor - ); -} +import { + deployer, + StackflowError, + address2, + address1, + address3, + stackflowContract, + CONFIRMATION_DEPTH, + MAX_HEIGHT, + generateClosePipeSignature, + address1PK, + address2PK, + WAITING_PERIOD, + generateTransferSignature, + PipeAction, + generateDepositSignature, + generateWithdrawSignature, + address3PK, + structuredDataHashWithPrefix, +} from "./utils"; describe("init", () => { it("can initialize the contract for STX", () => { @@ -282,7 +56,7 @@ describe("init", () => { [Cl.none()], deployer ); - expect(result).toBeErr(Cl.uint(TxError.AlreadyInitialized)); + expect(result).toBeErr(Cl.uint(StackflowError.AlreadyInitialized)); }); it("cannot fund a pipe before initializing the contract", () => { @@ -292,7 +66,7 @@ describe("init", () => { [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], address1 ); - expect(result).toBeErr(Cl.uint(TxError.NotInitialized)); + expect(result).toBeErr(Cl.uint(StackflowError.NotInitialized)); }); }); @@ -438,7 +212,7 @@ describe("fund-pipe", () => { ], address1 ); - expect(resultBefore).toBeErr(Cl.uint(TxError.InvalidBalances)); + expect(resultBefore).toBeErr(Cl.uint(StackflowError.InvalidBalances)); // Wait for the fund to confirm simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); @@ -555,7 +329,7 @@ describe("fund-pipe", () => { [Cl.none(), Cl.uint(2000000), Cl.principal(address2), Cl.uint(0)], address1 ); - expect(badResult).toBeErr(Cl.uint(TxError.AlreadyFunded)); + expect(badResult).toBeErr(Cl.uint(StackflowError.AlreadyFunded)); // Verify the pipe did not change const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -616,7 +390,7 @@ describe("fund-pipe", () => { [Cl.none(), Cl.uint(2000000), Cl.principal(address2), Cl.uint(0)], address1 ); - expect(badResult).toBeErr(Cl.uint(TxError.AlreadyPending)); + expect(badResult).toBeErr(Cl.uint(StackflowError.AlreadyPending)); // Verify the pipe did not change const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -710,7 +484,7 @@ describe("fund-pipe", () => { [Cl.none(), Cl.uint(3000000), Cl.principal(address1), Cl.uint(0)], address2 ); - expect(badResult).toBeErr(Cl.uint(TxError.AlreadyFunded)); + expect(badResult).toBeErr(Cl.uint(StackflowError.AlreadyFunded)); // Verify the pipe did not change const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -778,7 +552,7 @@ describe("fund-pipe", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.UnapprovedToken)); + expect(result).toBeErr(Cl.uint(StackflowError.UnapprovedToken)); }); it("can fund a pipe with an approved token", () => { @@ -1010,7 +784,7 @@ describe("fund-pipe", () => { ], address1 ); - expect(badResult).toBeErr(Cl.uint(TxError.AlreadyFunded)); + expect(badResult).toBeErr(Cl.uint(StackflowError.AlreadyFunded)); // Verify the pipe did not change const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -1138,7 +912,7 @@ describe("fund-pipe", () => { ], address2 ); - expect(badResult).toBeErr(Cl.uint(TxError.AlreadyFunded)); + expect(badResult).toBeErr(Cl.uint(StackflowError.AlreadyFunded)); // Verify the pipe did not change const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -1575,7 +1349,7 @@ describe("close-pipe", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidSenderSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -1647,7 +1421,7 @@ describe("close-pipe", () => { ], address2 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidOtherSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidOtherSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -1719,7 +1493,7 @@ describe("close-pipe", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidTotalBalance)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidTotalBalance)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -1880,7 +1654,7 @@ describe("force-cancel", () => { [Cl.none(), Cl.principal(address1)], address3 ); - expect(result).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); }); }); @@ -2133,7 +1907,7 @@ describe("force-close", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidSenderSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -2209,7 +1983,7 @@ describe("force-close", () => { ], address2 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidOtherSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidOtherSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -2285,7 +2059,7 @@ describe("force-close", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidTotalBalance)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidTotalBalance)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -2367,7 +2141,7 @@ describe("force-close", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.NotValidYet)); + expect(result).toBeErr(Cl.uint(StackflowError.NotValidYet)); }); it("cannot force-close with deposit signatures that were never applied", () => { @@ -2438,7 +2212,7 @@ describe("force-close", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidTotalBalance)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidTotalBalance)); }); it("cannot force-close with withdraw signatures that were never applied", () => { @@ -2509,7 +2283,7 @@ describe("force-close", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidTotalBalance)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidTotalBalance)); }); }); @@ -2575,7 +2349,7 @@ describe("dispute-closure", () => { ], address3 ); - expect(result).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); }); it("disputing a pipe that is not closing gives an error", () => { @@ -2644,7 +2418,7 @@ describe("dispute-closure", () => { ], address2 ); - expect(disputeResult).toBeErr(Cl.uint(TxError.NoCloseInProgress)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.NoCloseInProgress)); // Verify that the map entry is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -2971,7 +2745,7 @@ describe("dispute-closure", () => { ], address1 ); - expect(disputeResult).toBeErr(Cl.uint(TxError.SelfDispute)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.SelfDispute)); // Verify that the map entry is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -3185,7 +2959,7 @@ describe("agent-dispute-closure", () => { ], address3 ); - expect(result).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); }); it("disputing a pipe that is not closing gives an error", () => { @@ -3263,7 +3037,7 @@ describe("agent-dispute-closure", () => { ], address3 ); - expect(disputeResult).toBeErr(Cl.uint(TxError.NoCloseInProgress)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.NoCloseInProgress)); // Verify that the map entry is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -3617,7 +3391,7 @@ describe("agent-dispute-closure", () => { ], address3 ); - expect(disputeResult).toBeErr(Cl.uint(TxError.SelfDispute)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.SelfDispute)); // Verify that the map entry is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -3675,7 +3449,7 @@ describe("finalize", () => { [Cl.none(), Cl.principal(address1)], address3 ); - expect(result).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); }); it("finalizing a pipe that is not closing gives an error", () => { @@ -3710,7 +3484,7 @@ describe("finalize", () => { [Cl.none(), Cl.principal(address1)], address2 ); - expect(disputeResult).toBeErr(Cl.uint(TxError.NoCloseInProgress)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.NoCloseInProgress)); // Verify that the map entry is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -4038,7 +3812,7 @@ describe("finalize", () => { [Cl.none(), Cl.principal(address2)], address1 ); - expect(disputeResult).toBeErr(Cl.uint(TxError.NotExpired)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.NotExpired)); // Verify that the map entry is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -4427,7 +4201,7 @@ describe("deposit", () => { ], address1 ); - expect(result1).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result1).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); simnet.callPublicFn( "stackflow", @@ -4461,7 +4235,7 @@ describe("deposit", () => { ], address1 ); - expect(result2).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result2).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); }); it("can not deposit with bad signatures", () => { @@ -4529,7 +4303,7 @@ describe("deposit", () => { ], address2 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidSenderSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -4661,7 +4435,7 @@ describe("deposit", () => { address1 ); - expect(result2).toBeErr(Cl.uint(TxError.NonceTooLow)); + expect(result2).toBeErr(Cl.uint(StackflowError.NonceTooLow)); // Verify the balances did not change with the failed deposit const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -4754,7 +4528,7 @@ describe("deposit", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidBalances)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidBalances)); }); }); @@ -5027,7 +4801,7 @@ describe("withdraw", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidSenderSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -5132,7 +4906,7 @@ describe("withdraw", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidOtherSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidOtherSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -5237,7 +5011,7 @@ describe("withdraw", () => { address2 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidSenderSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -5391,7 +5165,7 @@ describe("withdraw", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.NonceTooLow)); + expect(result).toBeErr(Cl.uint(StackflowError.NonceTooLow)); // Verify the balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -5661,7 +5435,7 @@ describe("agent-dispute additional tests", () => { address3 ); - expect(result).toBeErr(Cl.uint(TxError.Unauthorized)); + expect(result).toBeErr(Cl.uint(StackflowError.Unauthorized)); }); }); @@ -5871,7 +5645,7 @@ describe("execute-withdraw", () => { [Cl.none(), Cl.uint(100)], address1 ); - expect(result).toBeErr(Cl.uint(TxError.WithdrawalFailed)); + expect(result).toBeErr(Cl.uint(StackflowError.WithdrawalFailed)); }); it("passes when the contract has a sufficient balance", () => { @@ -5926,7 +5700,7 @@ describe("execute-withdraw", () => { [Cl.none(), Cl.uint(101)], address1 ); - expect(result).toBeErr(Cl.uint(TxError.WithdrawalFailed)); + expect(result).toBeErr(Cl.uint(StackflowError.WithdrawalFailed)); // Verify the balances have not changed const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -6099,7 +5873,7 @@ describe("transfers with secrets", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidSenderSignature)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); // Verify that the map has not changed const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -6381,7 +6155,7 @@ describe("transfers with valid-after", () => { ], address1 ); - expect(result).toBeErr(Cl.uint(TxError.NotValidYet)); + expect(result).toBeErr(Cl.uint(StackflowError.NotValidYet)); // Verify that the map has not changed const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..9b32971 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,258 @@ +import { + Cl, + ClarityValue, + createStacksPrivateKey, + serializeCV, + signWithKey, + StacksPrivateKey, +} from "@stacks/transactions"; +import { createHash } from "crypto"; + +export const accounts = simnet.getAccounts(); +export const deployer = accounts.get("deployer")!; +export const address1 = accounts.get("wallet_1")!; +export const address2 = accounts.get("wallet_2")!; +export const address3 = accounts.get("wallet_3")!; +export const stackflowContract = `${deployer}.stackflow`; +export const reservoirContract = `${deployer}.reservoir`; + +export const deployerPK = createStacksPrivateKey( + "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601" +); +export const address1PK = createStacksPrivateKey( + "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801" +); +export const address2PK = createStacksPrivateKey( + "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101" +); +export const address3PK = createStacksPrivateKey( + "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901" +); + +export const WAITING_PERIOD = 144; +export const MAX_HEIGHT = 340282366920938463463374607431768211455n; +export const CONFIRMATION_DEPTH = 6; + +export enum PipeAction { + Close = 0, + Transfer = 1, + Deposit = 2, + Withdraw = 3, +} + +export enum StackflowError { + DepositFailed = 100, + NoSuchPipe = 101, + InvalidPrincipal = 102, + InvalidSenderSignature = 103, + InvalidOtherSignature = 104, + ConsensusBuff = 105, + Unauthorized = 106, + MaxAllowed = 107, + InvalidTotalBalance = 108, + WithdrawalFailed = 109, + PipeExpired = 110, + NonceTooLow = 111, + CloseInProgress = 112, + NoCloseInProgress = 113, + SelfDispute = 114, + AlreadyFunded = 115, + InvalidWithdrawal = 116, + UnapprovedToken = 117, + NotExpired = 118, + NotInitialized = 119, + AlreadyInitialized = 120, + NotValidYet = 121, + AlreadyPending = 122, + Pending = 123, + InvalidBalances = 124, + InvalidFee = 204, +} + +export enum ReservoirError { + BorrowFeePaymentFailed = 200, + Unauthorized = 201, + FundingFailed = 202, + TransferFailed = 203, + InvalidFee = 204, +} + +const structuredDataPrefix = Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); + +const chainIds = { + mainnet: 1, + testnet: 2147483648, +}; + +function sha256(data: Buffer): Buffer { + return createHash("sha256").update(data).digest(); +} + +function structuredDataHash(structuredData: ClarityValue): Buffer { + return sha256(Buffer.from(serializeCV(structuredData))); +} + +const domainHash = structuredDataHash( + Cl.tuple({ + name: Cl.stringAscii("StackFlow"), + version: Cl.stringAscii("0.6.0"), + "chain-id": Cl.uint(chainIds.testnet), + }) +); + +export function structuredDataHashWithPrefix( + structuredData: ClarityValue +): Buffer { + const messageHash = structuredDataHash(structuredData); + return sha256(Buffer.concat([structuredDataPrefix, domainHash, messageHash])); +} + +function signStructuredData( + privateKey: StacksPrivateKey, + structuredData: ClarityValue +): Buffer { + const hash = structuredDataHashWithPrefix(structuredData); + const data = signWithKey(privateKey, hash.toString("hex")).data; + return Buffer.from(data.slice(2) + data.slice(0, 2), "hex"); +} + +export function generatePipeSignature( + privateKey: StacksPrivateKey, + token: [string, string] | null, + myPrincipal: string, + theirPrincipal: string, + myBalance: number, + theirBalance: number, + nonce: number, + action: PipeAction, + actor: string, + secret: string | null = null, + valid_after: number | null = null +): Buffer { + const meFirst = serializeCV(Cl.principal(myPrincipal)) < serializeCV(Cl.principal(theirPrincipal)); + const principal1 = meFirst ? myPrincipal : theirPrincipal; + const principal2 = meFirst ? theirPrincipal : myPrincipal; + const balance1 = meFirst ? myBalance : theirBalance; + const balance2 = meFirst ? theirBalance : myBalance; + + const tokenCV = + token === null + ? Cl.none() + : Cl.some(Cl.contractPrincipal(token[0], token[1])); + const secretCV = + secret === null + ? Cl.none() + : Cl.some(Cl.buffer(sha256(Buffer.from(secret, "hex")))); + const validAfterCV = + valid_after === null ? Cl.none() : Cl.some(Cl.uint(valid_after)); + + const data = Cl.tuple({ + token: tokenCV, + "principal-1": Cl.principal(principal1), + "principal-2": Cl.principal(principal2), + "balance-1": Cl.uint(balance1), + "balance-2": Cl.uint(balance2), + nonce: Cl.uint(nonce), + action: Cl.uint(action), + actor: Cl.principal(actor), + "hashed-secret": secretCV, + "valid-after": validAfterCV, + }); + return signStructuredData(privateKey, data); +} + +export function generateClosePipeSignature( + privateKey: StacksPrivateKey, + token: [string, string] | null, + myPrincipal: string, + theirPrincipal: string, + myBalance: number, + theirBalance: number, + nonce: number, + actor: string +): Buffer { + return generatePipeSignature( + privateKey, + token, + myPrincipal, + theirPrincipal, + myBalance, + theirBalance, + nonce, + PipeAction.Close, + actor + ); +} + +export function generateTransferSignature( + privateKey: StacksPrivateKey, + token: [string, string] | null, + myPrincipal: string, + theirPrincipal: string, + myBalance: number, + theirBalance: number, + nonce: number, + actor: string, + secret: string | null = null, + valid_after: number | null = null +): Buffer { + return generatePipeSignature( + privateKey, + token, + myPrincipal, + theirPrincipal, + myBalance, + theirBalance, + nonce, + PipeAction.Transfer, + actor, + secret, + valid_after + ); +} + +export function generateDepositSignature( + privateKey: StacksPrivateKey, + token: [string, string] | null, + myPrincipal: string, + theirPrincipal: string, + myBalance: number, + theirBalance: number, + nonce: number, + actor: string +): Buffer { + return generatePipeSignature( + privateKey, + token, + myPrincipal, + theirPrincipal, + myBalance, + theirBalance, + nonce, + PipeAction.Deposit, + actor + ); +} + +export function generateWithdrawSignature( + privateKey: StacksPrivateKey, + token: [string, string] | null, + myPrincipal: string, + theirPrincipal: string, + myBalance: number, + theirBalance: number, + nonce: number, + actor: string +): Buffer { + return generatePipeSignature( + privateKey, + token, + myPrincipal, + theirPrincipal, + myBalance, + theirBalance, + nonce, + PipeAction.Withdraw, + actor + ); +} From 7d83e9f343bf12ec5af920e2c767aab9462ca31c Mon Sep 17 00:00:00 2001 From: obycode Date: Mon, 12 May 2025 08:38:51 -0400 Subject: [PATCH 05/78] feat: take in and validate the stackflow contract in Reservoir --- contracts/reservoir.clar | 58 ++++++++++++++++++++++++---- deployments/default.simnet-plan.yaml | 8 ++-- tests/reservoir.test.ts | 39 ++++++++++++++----- 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 9a2b0ef..04a2677 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -37,6 +37,8 @@ (define-constant ERR_INVALID_FEE (err u204)) (define-constant ERR_ALREADY_INITIALIZED (err u205)) (define-constant ERR_NOT_INITIALIZED (err u206)) +(define-constant ERR_UNAPPROVED_TOKEN (err u207)) +(define-constant ERR_INCORRECT_STACKFLOW (err u208)) ;;; Has this contract been initialized? (define-data-var initialized bool false) @@ -49,13 +51,19 @@ ;;; If `none`, only STX is supported. (define-data-var supported-token (optional principal) none) +;;; The StackFlow contract that this Reservoir is registered with. +(define-data-var stackflow-contract (optional principal) none) + ;;; Track the open taps ;; The key is the principal and the value is the height at which it was opened. -(define-map taps principal uint) +(define-map taps + principal + uint +) (define-public (init - (token (optional )) (stackflow ) + (token (optional )) (initial-borrow-rate uint) ) (begin @@ -65,6 +73,9 @@ ;; Set the token for this instance of the Reservoir contract. (var-set supported-token (contract-of-optional token)) + ;; Set the StackFlow contract for this instance of the Reservoir contract. + (var-set stackflow-contract (some (contract-of stackflow))) + ;; Authorize the operator as a StackFlow agent for this contract. (try! (as-contract (contract-call? stackflow register-agent OPERATOR))) @@ -89,13 +100,14 @@ ;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is in progress ;;; - `ERR_ALREADY_FUNDED` if the pipe has already been funded (define-public (fund-tap + (stackflow ) (token (optional )) (amount uint) (nonce uint) ) (begin - (asserts! (var-get initialized) ERR_NOT_INITIALIZED) - (contract-call? .stackflow fund-pipe token amount RESERVOIR nonce) + (try! (check-valid stackflow token)) + (contract-call? stackflow fund-pipe token amount RESERVOIR nonce) ) ) @@ -109,6 +121,7 @@ ;;; - `ERR_BORROW_FEE_PAYMENT_FAILED` if the fee payment failed ;;; - Errors passed through from the StackFlow `deposit` function (define-public (borrow-liquidity + (stackflow ) (amount uint) (fee uint) (token (optional )) @@ -122,7 +135,7 @@ (borrower tx-sender) (expected-fee (get-borrow-fee amount)) ) - (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (try! (check-valid stackflow token)) (asserts! (>= fee expected-fee) ERR_INVALID_FEE) (unwrap! (match token @@ -131,7 +144,7 @@ ) ERR_BORROW_FEE_PAYMENT_FAILED ) - (as-contract (contract-call? .stackflow deposit amount token borrower reservoir-balance + (as-contract (contract-call? stackflow deposit amount token borrower reservoir-balance my-balance reservoir-signature my-signature nonce )) ) @@ -167,7 +180,7 @@ ) (begin (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) - (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (try! (check-valid-token token)) (unwrap! (match token t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) @@ -209,3 +222,34 @@ none ) ) + +;;; Check if the Reservoir is initialized and the correct stackflow and token +;;; contracts are passed. +(define-private (check-valid + (stackflow ) + (token (optional )) + ) + (begin + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (asserts! (is-eq (some (contract-of stackflow)) (var-get stackflow-contract)) + ERR_INCORRECT_STACKFLOW + ) + (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) + ERR_UNAPPROVED_TOKEN + ) + (ok true) + ) +) + +;;; Check if the Reservoir is initialized and the correct token is passed. +(define-private (check-valid-token + (token (optional )) + ) + (begin + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) + ERR_UNAPPROVED_TOKEN + ) + (ok true) + ) +) diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index e9b6c61..a721514 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -86,13 +86,13 @@ plan: path: contracts/stackflow-token.clar clarity-version: 3 - emulated-contract-publish: - contract-name: stackflow + contract-name: reservoir emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow.clar + path: contracts/reservoir.clar clarity-version: 3 - emulated-contract-publish: - contract-name: reservoir + contract-name: stackflow emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/reservoir.clar + path: contracts/stackflow.clar clarity-version: 3 epoch: "3.1" diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index e335bf3..2557001 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { - Cl, - ClarityType, - ResponseOkCV, -} from "@stacks/transactions"; +import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; import { deployer, address1, @@ -28,7 +24,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "init", - [Cl.none(), Cl.principal(stackflowContract), Cl.uint(0)], + [Cl.principal(stackflowContract), Cl.none(), Cl.uint(0)], deployer ); }); @@ -86,7 +82,12 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "fund-tap", - [Cl.none(), Cl.uint(1000000), Cl.uint(0)], + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], address1 ); @@ -116,6 +117,7 @@ describe("reservoir", () => { "reservoir", "borrow-liquidity", [ + Cl.principal(stackflowContract), Cl.uint(1000), // amount Cl.uint(50), // incorrect fee (should be 100) Cl.none(), // token @@ -206,7 +208,12 @@ describe("reservoir", () => { const { result } = simnet.callPublicFn( "reservoir", "fund-tap", - [Cl.none(), Cl.uint(1000000), Cl.uint(0)], + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], address1 ); expect(result).toBeOk( @@ -244,7 +251,12 @@ describe("reservoir", () => { const { result } = simnet.callPublicFn( "reservoir", "fund-tap", - [Cl.none(), Cl.uint(1000000), Cl.uint(0)], + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], address1 ); expect(result.type).toBe(ClarityType.ResponseOk); @@ -279,6 +291,7 @@ describe("reservoir", () => { "reservoir", "borrow-liquidity", [ + Cl.principal(stackflowContract), Cl.uint(amount), Cl.uint(fee), Cl.none(), @@ -355,7 +368,12 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "fund-tap", - [Cl.none(), Cl.uint(50000), Cl.uint(0)], + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(50000), + Cl.uint(0), + ], address1 ); @@ -389,6 +407,7 @@ describe("reservoir", () => { "reservoir", "borrow-liquidity", [ + Cl.principal(stackflowContract), Cl.uint(amount), Cl.uint(fee), Cl.none(), From 1ae1a20a80ee92c3edd0bf08dcfaa9905619245a Mon Sep 17 00:00:00 2001 From: obycode Date: Tue, 13 May 2025 09:36:03 -0400 Subject: [PATCH 06/78] feat: add `add-funds` to Reservoir --- Clarinet.toml | 1 + contracts/reservoir.clar | 52 ++++++++--- contracts/stackflow.clar | 3 +- deployments/default.simnet-plan.yaml | 8 +- tests/reservoir.test.ts | 132 ++++++++++++++++++++++++++- 5 files changed, 173 insertions(+), 23 deletions(-) diff --git a/Clarinet.toml b/Clarinet.toml index 45fa451..16e7d86 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -10,6 +10,7 @@ contract_id = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standa [[project.requirements]] contract_id = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.sip-010-trait-ft-standard' + [contracts.reservoir] path = 'contracts/reservoir.clar' clarity_version = 3 diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 04a2677..e7fbe23 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -54,13 +54,6 @@ ;;; The StackFlow contract that this Reservoir is registered with. (define-data-var stackflow-contract (optional principal) none) -;;; Track the open taps -;; The key is the principal and the value is the height at which it was opened. -(define-map taps - principal - uint -) - (define-public (init (stackflow ) (token (optional )) @@ -87,19 +80,20 @@ ) ) -;;; Deposit `amount` funds into an unfunded tap for FT `token` (`none` -;;; indicates STX). Create the tap if one does not already exist. +;;; Create a new tap for FT `token` (`none` indicates STX) and deposit +;;; `amount` funds into it. ;;; Returns: ;;; - The pipe key on success ;;; ``` ;;; { token: (optional principal), principal-1: principal, principal-2: principal } ;;; ``` ;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one ;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token ;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce ;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is in progress ;;; - `ERR_ALREADY_FUNDED` if the pipe has already been funded -(define-public (fund-tap +(define-public (create-tap (stackflow ) (token (optional )) (amount uint) @@ -111,6 +105,40 @@ ) ) +;;; Deposit `amount` additional funds into an existing pipe between +;;; `tx-sender` and `with` for FT `token` (`none` indicates STX). Signatures +;;; must confirm the deposit and the new balances. +;;; Returns: +;;; -`(ok pipe-key)` on success +;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one +;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token +;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist +;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is in progress +;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce +;;; - `ERR_INVALID_TOTAL_BALANCE` if the total balance of the pipe is not +;;; equal to the sum of the balances provided and the deposit amount +;;; - `ERR_INVALID_SENDER_SIGNATURE` if the sender's signature is invalid +;;; - `ERR_INVALID_OTHER_SIGNATURE` if the other party's signature is invalid +;;; - `ERR_DEPOSIT_FAILED` if the deposit fails +(define-public (add-funds + (stackflow ) + (amount uint) + (token (optional )) + (my-balance uint) + (their-balance uint) + (my-signature (buff 65)) + (their-signature (buff 65)) + (nonce uint) + ) + (begin + (try! (check-valid stackflow token)) + (contract-call? .stackflow deposit amount token RESERVOIR my-balance + their-balance my-signature their-signature nonce + ) + ) +) + ;;; Borrow `amount` from the reservoir to add receiving capacity to the ;;; caller's tap. The caller pays a fee of `fee` to the reservoir. The caller ;;; provides their own signature for the deposit, as well as a signature that @@ -242,9 +270,7 @@ ) ;;; Check if the Reservoir is initialized and the correct token is passed. -(define-private (check-valid-token - (token (optional )) - ) +(define-private (check-valid-token (token (optional ))) (begin (asserts! (var-get initialized) ERR_NOT_INITIALIZED) (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 30a3665..08fabe9 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -182,8 +182,7 @@ (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (existing-pipe (map-get? pipes pipe-key)) (pipe (match existing-pipe - ch - ch + ch ch { balance-1: u0, balance-2: u0, diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index a721514..e9b6c61 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -86,13 +86,13 @@ plan: path: contracts/stackflow-token.clar clarity-version: 3 - emulated-contract-publish: - contract-name: reservoir + contract-name: stackflow emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/reservoir.clar + path: contracts/stackflow.clar clarity-version: 3 - emulated-contract-publish: - contract-name: stackflow + contract-name: reservoir emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow.clar + path: contracts/reservoir.clar clarity-version: 3 epoch: "3.1" diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 2557001..101dc71 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -81,7 +81,7 @@ describe("reservoir", () => { // Fund initial tap simnet.callPublicFn( "reservoir", - "fund-tap", + "create-tap", [ Cl.principal(stackflowContract), Cl.none(), @@ -207,7 +207,7 @@ describe("reservoir", () => { it("can fund new tap", () => { const { result } = simnet.callPublicFn( "reservoir", - "fund-tap", + "create-tap", [ Cl.principal(stackflowContract), Cl.none(), @@ -250,7 +250,7 @@ describe("reservoir", () => { // Fund initial tap const { result } = simnet.callPublicFn( "reservoir", - "fund-tap", + "create-tap", [ Cl.principal(stackflowContract), Cl.none(), @@ -367,7 +367,7 @@ describe("reservoir", () => { // Fund initial tap simnet.callPublicFn( "reservoir", - "fund-tap", + "create-tap", [ Cl.principal(stackflowContract), Cl.none(), @@ -422,4 +422,128 @@ describe("reservoir", () => { expect(result).toBeErr(Cl.uint(StackflowError.DepositFailed)); }); }); + + describe("adding funds to tap", () => { + beforeEach(() => { + // Create initial tap with some funds + simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), // Initial balance + Cl.uint(0), + ], + address1 + ); + + // Wait for the fund to confirm + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + }); + + it("can add funds to existing tap", () => { + const additionalAmount = 500000; + const nonce = 1; + const currentBalance = 1000000; + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + currentBalance + additionalAmount, + 0, + nonce, + address1 + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 0, + currentBalance + additionalAmount, + nonce, + address1 + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "add-funds", + [ + Cl.principal(stackflowContract), + Cl.uint(additionalAmount), + Cl.none(), + Cl.uint(currentBalance + additionalAmount), + Cl.uint(0), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(nonce), + ], + address1 + ); + + expect(result).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(reservoirContract), + }) + ); + + // Verify the updated balance in the tap + const stxBalances = simnet.getAssetsMap().get("STX")!; + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(1500000n); // Initial 1000000 + additional 500000 + }); + + it("fails with invalid signatures", () => { + const additionalAmount = 500000; + const nonce = 1; + const currentBalance = 1000000; + + // Generate invalid signature by using wrong nonce + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + currentBalance + additionalAmount, + 0, + nonce + 1, // Wrong nonce + address1 + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 0, + currentBalance + additionalAmount, + nonce, + address1 + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "add-funds", + [ + Cl.principal(stackflowContract), + Cl.uint(additionalAmount), + Cl.none(), + Cl.uint(currentBalance + additionalAmount), + Cl.uint(0), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(nonce), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); + }); + }); }); From 14c80b1e23e5458c92f5798891f92f9be11f317f Mon Sep 17 00:00:00 2001 From: obycode Date: Tue, 13 May 2025 10:22:58 -0400 Subject: [PATCH 07/78] chore: formatting --- contracts/stackflow.clar | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 08fabe9..63f2c26 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -32,7 +32,7 @@ ;; (impl-trait 'SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token) (impl-trait .stackflow-token.stackflow-token) -(define-constant contract-deployer tx-sender) +(define-constant CONTRACT_DEPLOYER tx-sender) (define-constant MAX_HEIGHT u340282366920938463463374607431768211455) (define-constant WAITING_PERIOD u144) ;; 24 hours in blocks @@ -130,7 +130,7 @@ (define-public (init (token (optional ))) (begin (asserts! (not (var-get initialized)) ERR_ALREADY_INITIALIZED) - (asserts! (is-eq tx-sender contract-deployer) ERR_UNAUTHORIZED) + (asserts! (is-eq tx-sender CONTRACT_DEPLOYER) ERR_UNAUTHORIZED) (var-set supported-token (contract-of-optional token)) (ok (var-set initialized true)) ) From 3e4f76a8cf776ec453a886aa82771792c2774561 Mon Sep 17 00:00:00 2001 From: obycode Date: Tue, 13 May 2025 10:24:08 -0400 Subject: [PATCH 08/78] chore: minor updates --- tests/utils.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/utils.ts b/tests/utils.ts index 9b32971..2a56613 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -75,6 +75,10 @@ export enum ReservoirError { FundingFailed = 202, TransferFailed = 203, InvalidFee = 204, + AlreadyInitialized = 205, + NotInitialized = 206, + UnapprovedToken = 207, + IncorrectStackflow = 208, } const structuredDataPrefix = Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); @@ -129,7 +133,9 @@ export function generatePipeSignature( secret: string | null = null, valid_after: number | null = null ): Buffer { - const meFirst = serializeCV(Cl.principal(myPrincipal)) < serializeCV(Cl.principal(theirPrincipal)); + const meFirst = + serializeCV(Cl.principal(myPrincipal)) < + serializeCV(Cl.principal(theirPrincipal)); const principal1 = meFirst ? myPrincipal : theirPrincipal; const principal2 = meFirst ? theirPrincipal : myPrincipal; const balance1 = meFirst ? myBalance : theirBalance; From 23030ca4028e439bf2aaf9ba30eadf9902823ef0 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 28 May 2025 21:31:05 -0400 Subject: [PATCH 09/78] feat: complete and test `verify-signature` This is the read-only function that should be called to validate a signature received off-chain to be confident that it could be used on-chain later if needed. --- contracts/stackflow.clar | 128 ++++++++++++---- tests/stackflow.test.ts | 305 ++++++++++++++++++++++++++++++++++++++- tests/utils.ts | 3 +- 3 files changed, 408 insertions(+), 28 deletions(-) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 63f2c26..8edbd8f 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -77,6 +77,7 @@ (define-constant ERR_ALREADY_PENDING (err u122)) (define-constant ERR_PENDING (err u123)) (define-constant ERR_INVALID_BALANCES (err u124)) +(define-constant ERR_INVALID_SIGNATURE (err u125)) ;; Number of burn blocks to wait before considering an on-chain action finalized. (define-constant CONFIRMATION_DEPTH u6) @@ -851,11 +852,19 @@ ) ) -;;; Validates that `signature` is a valid signature from `signer for the -;;; structured data constructed from the other arguments. +;;; Validates that the specified data is valid for the pipe and that +;;; `signature` is a valid signature from `signer` for this data. ;;; Returns: -;;; - `true` if the signature is valid. -;;; - `false` if the signature is invalid. +;;; - `(ok none)` if the signature is valid now +;;; - `(ok (some burn-block))` if the signature will be valid at `burn-block` +;;; - `ERR_CONSENSUS_BUFF` if the structured data cannot be converted to a +;;; consensus buff +;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist +;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce +;;; - `ERR_INVALID_TOTAL_BALANCE` if the total balance is invalid +;;; - `ERR_INVALID_BALANCES` if the balances would require spending pending +;;; deposits +;;; - `ERR_INVALID_SENDER_SIGNATURE` if the signature is invalid (define-read-only (verify-signature (signature (buff 65)) (signer principal) @@ -872,13 +881,59 @@ (hashed-secret (optional (buff 32))) (valid-after (optional uint)) ) - (let ((hash (unwrap! - (make-structured-data-hash pipe-key balance-1 balance-2 nonce action actor + (let ( + (hash (try! (make-structured-data-hash pipe-key balance-1 balance-2 nonce action actor hashed-secret valid-after - ) - false + ))) + (after (default-to burn-block-height valid-after)) + ) + (try! (balance-check pipe-key balance-1 balance-2 valid-after)) + (try! (nonce-check pipe-key nonce)) + (try! (verify-hash-signature hash signature signer actor)) + (if (> after burn-block-height) + (ok (some (- after burn-block-height))) + (ok none) + ) + ) +) + +;;; Validates that the specified data is valid for the pipe and that +;;; `signature` is a valid signature from `signer` for this data with the +;;; provided `secret`. +;;; Returns: +;;; - `(ok none)` if the signature is valid now +;;; - `(ok (some burn-block))` if the signature will be valid at `burn-block` +;;; - `ERR_CONSENSUS_BUFF` if the structured data cannot be converted to a +;;; consensus buff +;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist +;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce +;;; - `ERR_INVALID_TOTAL_BALANCE` if the total balance is invalid +;;; - `ERR_INVALID_BALANCES` if the balances would require spending pending +;;; deposits +;;; - `ERR_INVALID_SENDER_SIGNATURE` if the signature is invalid +(define-read-only (verify-signature-with-secret + (signature (buff 65)) + (signer principal) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) + (balance-1 uint) + (balance-2 uint) + (nonce uint) + (action uint) + (actor principal) + (secret (optional (buff 32))) + (valid-after (optional uint)) + ) + (let ((hashed-secret (match secret + s (some (sha256 s)) + none ))) - (verify-hash-signature hash signature signer actor) + (verify-signature signature signer pipe-key balance-1 balance-2 nonce action + actor hashed-secret valid-after + ) ) ) @@ -886,9 +941,10 @@ ;;; `signer-1` and `signer-2`, respectively, for the structured data ;;; constructed from the other arguments. ;;; Returns: -;;; - `(ok true)` if both signatures are valid. -;;; - `ERR_INVALID_SENDER_SIGNATURE` if the first signature is invalid. -;;; - `ERR_INVALID_OTHER_SIGNATURE` if the second signature is invalid. +;;; - `(ok true)` if both signatures are valid +;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist +;;; - `ERR_INVALID_SENDER_SIGNATURE` if the first signature is invalid +;;; - `ERR_INVALID_OTHER_SIGNATURE` if the second signature is invalid (define-read-only (verify-signatures (signature-1 (buff 65)) (signer-1 principal) @@ -917,10 +973,10 @@ ))) ) (try! (balance-check pipe-key balance-1 balance-2 valid-after)) - (asserts! (verify-hash-signature hash signature-1 signer-1 actor) + (unwrap! (verify-hash-signature hash signature-1 signer-1 actor) ERR_INVALID_SENDER_SIGNATURE ) - (asserts! (verify-hash-signature hash signature-2 signer-2 actor) + (unwrap! (verify-hash-signature hash signature-2 signer-2 actor) ERR_INVALID_OTHER_SIGNATURE ) (ok true) @@ -1223,25 +1279,31 @@ ) ;;; Verify a signature for a hash. -;;; Returns `true` if the signature is valid, `false` otherwise. +;;; Returns: +;;; - `(ok true)` if the signature is valid +;;; - `ERR_INVALID_SIGNATURE` if the signature is invalid (define-private (verify-hash-signature (hash (buff 32)) (signature (buff 65)) (signer principal) (actor principal) ) - (or - (is-eq (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) - (ok signer) - ) - ;; Check if the signer is an agent of the actor - (match (map-get? agents signer) - agent (is-eq - (principal-of? (unwrap! (secp256k1-recover? hash signature) false)) - (ok agent) + (let ((recovered (unwrap! + (principal-of? (unwrap! (secp256k1-recover? hash signature) ERR_INVALID_SIGNATURE)) + ERR_INVALID_SIGNATURE + ))) + (asserts! + (or + (is-eq recovered signer) + ;; Check if the signer is an agent of the actor + (match (map-get? agents signer) + agent (is-eq recovered agent) + false + ) ) - false + ERR_INVALID_SIGNATURE ) + (ok true) ) ) @@ -1401,3 +1463,19 @@ } ) ) + +;;; Check that the nonce is valid for the pipe. +(define-private (nonce-check + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) + (nonce uint) + ) + (let ((pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE))) + ;; Nonce must be greater than the pipe nonce + (asserts! (> nonce (get nonce pipe)) ERR_NONCE_TOO_LOW) + (ok true) + ) +) diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 526ffd8..60b9660 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -1,5 +1,10 @@ -import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; -import { describe, expect, it } from "vitest"; +import { + Cl, + ClarityType, + ClarityValue, + ResponseOkCV, +} from "@stacks/transactions"; +import { beforeEach, describe, expect, it } from "vitest"; import { deployer, StackflowError, @@ -6193,3 +6198,299 @@ describe("transfers with valid-after", () => { expect(contractBalance).toBe(3000000n); }); }); + +// `verify-signature` is the read-only function that users can call off-chain +// to validate a signature. +describe("verify-signature", () => { + var pipeKey: ClarityValue; + + // Setup - ensure contract is initialized + beforeEach(() => { + // Initialize the contract + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + // Fund a pipe + let { result } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(10)], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + pipeKey = (result as ResponseOkCV).value; + + // Mine blocks to confirm the transaction + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + }); + + it("validates a valid signature", () => { + const balance1 = 600000; + const balance2 = 400000; + const nonce = 11; + + const signature = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address2 + ); + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Transfer), + Cl.principal(address2), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeOk(Cl.none()); + }); + + it("rejects signature from wrong signer", () => { + const balance1 = 500000; + const balance2 = 500000; + const nonce = 21; + + const signature1 = generateClosePipeSignature( + address3PK, // Wrong signer + null, + address1, + address2, + balance1, + balance2, + nonce, + address1 + ); + + // Using the wrong signer for the signature + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature1), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Close), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSignature)); + }); + + it("rejects signature over the wrong data", () => { + const balance1 = 500000; + const balance2 = 500000; + const nonce = 21; + + const signature1 = generateClosePipeSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address1 + ); + + // Using the wrong signer for the signature + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature1), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce + 1), // Different nonce + Cl.uint(PipeAction.Close), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSignature)); + }); + + it("rejects signature with invalid balances", () => { + const balance1 = 600000; + const balance2 = 500000; + const nonce = 21; + + const signature1 = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address1 + ); + + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature1), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Close), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.InvalidTotalBalance)); + }); + + it("rejects signature with invalid nonce", () => { + const balance1 = 700000; + const balance2 = 300000; + const nonce = 10; // Nonce is too low, should be > 10 + + const signature1 = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address1 + ); + + // Using the wrong signer for the signature + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature1), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Close), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.NonceTooLow)); + }); + + it("accepts valid signature with past `valid-after`", () => { + const balance1 = 1000000; + const balance2 = 0; + const nonce = 11; + const validAfter = simnet.burnBlockHeight - 2; // Past block height + + const signature1 = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address1, + null, + validAfter + ); + + // Using the wrong signer for the signature + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature1), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Transfer), + Cl.principal(address1), + Cl.none(), + Cl.some(Cl.uint(validAfter)), + ], + address1 + ); + + expect(result).toBeOk(Cl.none()); + }); + + it("accepts valid signature with future `valid-after`", () => { + const balance1 = 1000000; + const balance2 = 0; + const nonce = 11; + const validAfter = simnet.burnBlockHeight + 2; // Future block height + + const signature1 = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address1, + null, + validAfter + ); + + // Using the wrong signer for the signature + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature", + [ + Cl.buffer(signature1), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Transfer), + Cl.principal(address1), + Cl.none(), + Cl.some(Cl.uint(validAfter)), + ], + address1 + ); + + expect(result).toBeOk( + Cl.some(Cl.uint(validAfter - simnet.burnBlockHeight)) + ); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 2a56613..ee6389a 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -66,6 +66,7 @@ export enum StackflowError { AlreadyPending = 122, Pending = 123, InvalidBalances = 124, + InvalidSignature = 125, InvalidFee = 204, } @@ -111,7 +112,7 @@ export function structuredDataHashWithPrefix( return sha256(Buffer.concat([structuredDataPrefix, domainHash, messageHash])); } -function signStructuredData( +export function signStructuredData( privateKey: StacksPrivateKey, structuredData: ClarityValue ): Buffer { From cf8902c99b521be1674719e18d5dc086442bdad6 Mon Sep 17 00:00:00 2001 From: obycode Date: Thu, 29 May 2025 09:22:04 -0400 Subject: [PATCH 10/78] test: add tests for `verify-signature-with-secret` --- tests/stackflow.test.ts | 103 ++++++++++++++++++++++++++++++++++++++++ tests/utils.ts | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 60b9660..54d846a 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -24,6 +24,7 @@ import { generateWithdrawSignature, address3PK, structuredDataHashWithPrefix, + sha256, } from "./utils"; describe("init", () => { @@ -6494,3 +6495,105 @@ describe("verify-signature", () => { ); }); }); + +// `verify-signature-with-secret` is the read-only function that users can call +// off-chain to validate a signature and the corresponding secret. +describe("verify-signature-with-secret", () => { + var pipeKey: ClarityValue; + + // Setup - ensure contract is initialized + beforeEach(() => { + // Initialize the contract + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + // Fund a pipe + let { result } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(10)], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + pipeKey = (result as ResponseOkCV).value; + + // Mine blocks to confirm the transaction + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + }); + + it("validates a valid signature with valid secret", () => { + const balance1 = 600000; + const balance2 = 400000; + const nonce = 11; + const secret = "01234567890abcdef"; + + const signature = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address2, + secret + ); + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature-with-secret", + [ + Cl.buffer(signature), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Transfer), + Cl.principal(address2), + Cl.some(Cl.buffer(Buffer.from(secret, "hex"))), + Cl.none(), + ], + address1 + ); + + expect(result).toBeOk(Cl.none()); + }); + + it("fails with an invalid secret", () => { + const balance1 = 600000; + const balance2 = 400000; + const nonce = 11; + const secret = "0123456789abcdef"; + const invalid = "0123456789abcdee"; + + const signature = generateTransferSignature( + address1PK, + null, + address1, + address2, + balance1, + balance2, + nonce, + address2, + secret + ); + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature-with-secret", + [ + Cl.buffer(signature), + Cl.principal(address1), + pipeKey, + Cl.uint(balance1), + Cl.uint(balance2), + Cl.uint(nonce), + Cl.uint(PipeAction.Transfer), + Cl.principal(address2), + Cl.some(Cl.buffer(Buffer.from(invalid, "hex"))), + Cl.none(), + ], + address1 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.InvalidSignature)); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index ee6389a..ef08320 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -89,7 +89,7 @@ const chainIds = { testnet: 2147483648, }; -function sha256(data: Buffer): Buffer { +export function sha256(data: Buffer): Buffer { return createHash("sha256").update(data).digest(); } From 07bc26b65e39d318e68e8f1fab4649876ae2a1f1 Mon Sep 17 00:00:00 2001 From: obycode Date: Sun, 1 Jun 2025 08:52:16 -0400 Subject: [PATCH 11/78] feat: add support for liquidity providers --- contracts/reservoir.clar | 117 ++++++++++++++++--- contracts/stackflow.clar | 26 ++++- tests/reservoir.test.ts | 245 ++++++++++++++++++++++++++++++++++----- tests/utils.ts | 2 + 4 files changed, 346 insertions(+), 44 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index e7fbe23..cd26004 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -39,14 +39,20 @@ (define-constant ERR_NOT_INITIALIZED (err u206)) (define-constant ERR_UNAPPROVED_TOKEN (err u207)) (define-constant ERR_INCORRECT_STACKFLOW (err u208)) +(define-constant ERR_AMOUNT_TOO_LOW (err u209)) +(define-constant ERR_LIQUIDITY_POOL_FULL (err u210)) ;;; Has this contract been initialized? (define-data-var initialized bool false) -;; Current borrow rate in basis points (1 = 0.01%) -;; For example, 1000 = 10% +;;; Current borrow rate in basis points (1 = 0.01%) +;;; For example, 1000 = 10% (define-data-var borrow-rate uint u0) +;;; Current minimum amount for liquidity providers to add to the reservoir +;;; Initially set to 1,000 STX +(define-data-var min-liquidity-amount uint u1000000000) + ;;; The token supported by this instance of the Reservoir contract. ;;; If `none`, only STX is supported. (define-data-var supported-token (optional principal) none) @@ -54,6 +60,16 @@ ;;; The StackFlow contract that this Reservoir is registered with. (define-data-var stackflow-contract (optional principal) none) +;;; The list of providers funding the Reservoir. +(define-data-var providers (list 256 principal) (list)) +(define-constant MAX_PROVIDERS u256) + +;;; Map of the liquidity provided by each provider. +(define-map liquidity + principal + uint +) + (define-public (init (stackflow ) (token (optional )) @@ -196,19 +212,21 @@ (/ (* amount (var-get borrow-rate)) u10000) ) -;;; As the operator, add `amount` of STX or FT `token` to the reservoir for -;;; borrowing. +;;; As a provider, add `amount` of STX or FT `token` to the reservoir for +;;; borrowing. Providers must add at least min-liquidity-amount. ;;; Returns: ;;; - `(ok true)` on success -;;; - `ERR_UNAUTHORIZED` if the caller is not the operator +;;; - `ERR_AMOUNT_TOO_LOW` if the amount is less than min-liquidity-amount +;;; - `ERR_LIQUIDITY_POOL_FULL` if the maximum number of providers is reached ;;; - `ERR_FUNDING_FAILED` if the funding failed (define-public (add-liquidity (token (optional )) (amount uint) ) (begin - (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) (try! (check-valid-token token)) + (asserts! (>= amount (var-get min-liquidity-amount)) ERR_AMOUNT_TOO_LOW) + (asserts! (< (len (var-get providers)) MAX_PROVIDERS) ERR_LIQUIDITY_POOL_FULL) (unwrap! (match token t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) @@ -216,33 +234,100 @@ ) ERR_FUNDING_FAILED ) + + ;; Credit this provider with the amount of liquidity they added. + (match (map-get? liquidity tx-sender) + ;; If the provider already exists, update their liquidity. + current + (map-set liquidity tx-sender (+ current amount)) + ;; If the provider does not exist, add them to the list of providers. + (begin + (map-set liquidity tx-sender amount) + (var-set providers + (unwrap! (as-max-len? (append (var-get providers) tx-sender) u256) + ERR_LIQUIDITY_POOL_FULL + )) + ) + ) (ok true) ) ) -;;; As the operator, remove `amount` of STX or FT `token` from the reservoir. +;;; As a liquidity provider, remove `amount` of STX or FT `token` from the +;;; reservoir. If this would leave the provider with less than +;;; `min-liquidity-amount`, then the full amount will be removed. ;;; Returns: -;;; - `(ok true)` on success -;;; - `ERR_UNAUTHORIZED` if the caller is not the operator +;;; - `(ok uint)` on success, where `uint` is the amount removed +;;; - `ERR_UNAUTHORIZED` if the caller is not a liquidity provider ;;; - `ERR_TRANSFER_FAILED` if the transfer failed -(define-public (remove-liquidity +(define-public (remove-liquidity-from-reservoir (token (optional )) (amount uint) ) - (begin - (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) - (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (let ( + (provider tx-sender) + (provider-liquidity (default-to u0 (map-get? liquidity provider))) + (adjusted-amount (if (and (<= amount provider-liquidity) + (< (- provider-liquidity amount) (var-get min-liquidity-amount))) + provider-liquidity + amount + )) + ) + (try! (check-valid-token token)) + ;; Ensure the provider has enough liquidity. + (asserts! (<= amount provider-liquidity) ERR_UNAUTHORIZED) + + ;; Update provider's liquidity + (let ( + (new-liquidity (- provider-liquidity adjusted-amount)) + ) + (if (is-eq new-liquidity u0) + ;; If provider is removing all liquidity, remove them from the list + (begin + (map-delete liquidity provider) + (var-set providers (filter remove-provider (var-get providers))) + ) + ;; Otherwise, just update their balance + (map-set liquidity provider new-liquidity) + ) + ) + + ;; Transfer the funds (unwrap! (as-contract (match token - t (contract-call? t transfer amount tx-sender OPERATOR none) - (stx-transfer? amount tx-sender OPERATOR) + t (contract-call? t transfer adjusted-amount tx-sender provider none) + (stx-transfer? adjusted-amount tx-sender provider) )) ERR_TRANSFER_FAILED ) - (ok true) + (ok adjusted-amount) ) ) +;; Filter function to remove a provider from the list +(define-private (remove-provider (p principal)) + (not (is-eq p tx-sender)) +) + +;; Set the minimum liquidity amount +(define-public (set-min-liquidity-amount (amount uint)) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (ok (var-set min-liquidity-amount amount)) + ) +) + +;; Get the total liquidity in the reservoir +(define-read-only (get-total-liquidity) + (fold + (map get-provider-liquidity (var-get providers)) u0) +) + +;; Get the liquidity for a provider +(define-private (get-provider-liquidity (provider principal)) + (default-to u0 (map-get? liquidity provider)) +) + ;;; Given an optional trait, return an optional principal for the trait. (define-private (contract-of-optional (trait (optional ))) (match trait diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 8edbd8f..d58cc46 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -291,6 +291,7 @@ (try! (verify-signatures my-signature tx-sender their-signature with pipe-key balance-1 balance-2 nonce ACTION_CLOSE tx-sender none none )) + ;; Reset the pipe in the map. (reset-pipe pipe-key nonce) @@ -338,6 +339,7 @@ ) ERR_PENDING ) + ;; Set the waiting period for this pipe. (map-set pipes pipe-key (merge settled-pipe { @@ -609,11 +611,11 @@ (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (pipe-nonce (get nonce pipe)) (closer (get closer pipe)) + (principal-1 (get principal-1 pipe-key)) ;; Ensure that the balance of the caller is not less than the deposit ;; amount, since that would indicate an invalid deposit. (balance-ok (asserts! (>= my-balance amount) ERR_INVALID_BALANCES)) - (principal-1 (get principal-1 pipe-key)) ;; These are the balances that both parties have signed off on, including ;; the deposit amount. @@ -628,6 +630,25 @@ (settled-pipe (settle-pending pipe-key pipe)) + ;; If the new balance of the pipe is not equal to the sum of the + ;; existing balances and the deposit amount, the deposit is invalid. + ;; Previously pending balances are included in the calculation. + (total-ok (asserts! + (is-eq (+ my-balance their-balance) + (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe) + (match (get pending-1 settled-pipe) + pending (get amount pending) + u0 + ) + (match (get pending-2 settled-pipe) + pending (get amount pending) + u0 + ) + amount + )) + ERR_INVALID_TOTAL_BALANCE + )) + ;; These are the settled balances that actually exist in the pipe while ;; the deposit is pending. (pre-balance-1 (if (is-eq tx-sender principal-1) @@ -696,6 +717,7 @@ my-signature: my-signature, their-signature: their-signature, }) + (ok pipe-key) ) ) @@ -786,6 +808,7 @@ my-signature: my-signature, their-signature: their-signature, }) + (ok pipe-key) ) ) @@ -1171,6 +1194,7 @@ (try! (verify-signatures my-signature for their-signature with pipe-key balance-1 balance-2 nonce action actor secret valid-after )) + ;; Reset the pipe in the map. (reset-pipe pipe-key nonce) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 101dc71..6fb6e87 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; +import { Cl, ClarityType, ResponseOkCV, UIntCV } from "@stacks/transactions"; import { deployer, address1, @@ -27,6 +27,14 @@ describe("reservoir", () => { [Cl.principal(stackflowContract), Cl.none(), Cl.uint(0)], deployer ); + + // Set minimum liquidity amount to a lower value for testing + simnet.callPublicFn( + "reservoir", + "set-min-liquidity-amount", + [Cl.uint(100000)], + deployer + ); }); describe("borrow rate", () => { @@ -134,11 +142,11 @@ describe("reservoir", () => { }); describe("liquidity management", () => { - it("operator can add STX liquidity", () => { + it("provider can add STX liquidity", () => { const { result } = simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(1000000)], + [Cl.none(), Cl.uint(1000000000)], deployer ); expect(result).toBeOk(Cl.bool(true)); @@ -146,61 +154,242 @@ describe("reservoir", () => { // Verify reservoir balance const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(1000000n); + expect(reservoirBalance).toBe(1000000000n); }); - it("operator can remove STX liquidity", () => { + it("provider can remove their own STX liquidity", () => { // First add liquidity simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(1000000)], + [Cl.none(), Cl.uint(1000000000)], deployer ); - // Then remove some + // Then remove some (leaving more than the minimum) const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity", - [Cl.none(), Cl.uint(500000)], + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(800000)], deployer ); - expect(result).toBeOk(Cl.bool(true)); + expect(result).toBeOk(Cl.uint(800000)); // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(500000n); + expect(reservoirBalance).toBe(999200000n); }); - it("non-operator cannot add liquidity", () => { + it("multiple providers can add liquidity", () => { + // Add liquidity from deployer + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(2000000000)], + deployer + ); + + // Add liquidity from address1 const { result } = simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(1000000)], + [Cl.none(), Cl.uint(1000000000)], address1 ); + expect(result).toBeOk(Cl.bool(true)); + + // Verify reservoir balance (should be sum of both) + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(3000000000n); + }); + + it("provider cannot remove more than they provided", () => { + // Add liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000000)], + deployer + ); + + // Try to remove more than provided + const { result } = simnet.callPublicFn( + "reservoir", + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(2000000000)], + deployer + ); expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); }); - it("non-operator cannot remove liquidity", () => { - // First add liquidity as operator + it("provider cannot remove below min-liquidity-amount", () => { + // Add liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(150000)], + deployer + ); + + // Try to leave less than min-liquidity-amount + const { result } = simnet.callPublicFn( + "reservoir", + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(100000)], + deployer + ); + // Should return the full remaining balance + expect(result).toBeOk(Cl.uint(150000)); + + // Verify reservoir balance + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(0n); + + // Verify provider is removed + const providers = simnet.getDataVar(reservoirContract, "providers"); + expect(providers).toBeList([]); + + // Verify liquidity entry is removed + const liquidity = simnet.getMapEntry( + reservoirContract, + "liquidity", + Cl.principal(deployer) + ); + expect(liquidity).toBeNone(); + }); + + it("provider is removed when they withdraw all liquidity", () => { + // Add liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(200000)], + deployer + ); + + // Add more liquidity from another provider + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(500000)], + address1 + ); + + // First provider removes all their liquidity + const { result } = simnet.callPublicFn( + "reservoir", + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(200000)], + deployer + ); + expect(result).toBeOk(Cl.uint(200000)); + + // Verify provider is removed + const providers = simnet.getDataVar(reservoirContract, "providers"); + expect(providers).toBeList([Cl.principal(address1)]); + + // Verify liquidity entry is removed + const liquidity = simnet.getMapEntry( + reservoirContract, + "liquidity", + Cl.principal(deployer) + ); + expect(liquidity).toBeNone(); + + // Second provider should now be the only one + // Try a second provider to remove more than they have - should fail + const result2 = simnet.callPublicFn( + "reservoir", + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(600000)], + address1 + ); + expect(result2.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + + // But they should be able to remove part of what they put in, leaving the minimum + const result3 = simnet.callPublicFn( + "reservoir", + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(400000)], + address1 + ); + expect(result3.result).toBeOk(Cl.uint(400000)); + }); + + it("non-provider cannot remove liquidity", () => { + // First add liquidity as deployer simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(1000000)], + [Cl.none(), Cl.uint(2000000000)], deployer ); - // Try to remove as non-operator + // Try to remove as non-provider const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity", + "remove-liquidity-from-reservoir", [Cl.none(), Cl.uint(500000)], address1 ); expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); }); + + it("calculates total liquidity correctly", () => { + // Add liquidity from first provider + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(200000)], + deployer + ); + + // Check total liquidity + let { result: result1 } = simnet.callReadOnlyFn( + "reservoir", + "get-total-liquidity", + [], + deployer + ); + expect(result1).toBeUint(200000); + + // Add liquidity from second provider + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(150000)], + address1 + ); + + // Check updated total liquidity + const { result: result2 } = simnet.callReadOnlyFn( + "reservoir", + "get-total-liquidity", + [], + deployer + ); + expect(result2).toBeUint(350000); + + // Remove some liquidity + simnet.callPublicFn( + "reservoir", + "remove-liquidity-from-reservoir", + [Cl.none(), Cl.uint(50000)], + deployer + ); + + // Check updated total liquidity after removal + const { result: result3 } = simnet.callReadOnlyFn( + "reservoir", + "get-total-liquidity", + [], + deployer + ); + expect(result3).toBeUint(300000); + }); }); describe("tap management", () => { @@ -243,7 +432,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(2000000)], + [Cl.none(), Cl.uint(500000)], deployer ); @@ -262,8 +451,8 @@ describe("reservoir", () => { expect(result.type).toBe(ClarityType.ResponseOk); const pipeKey = (result as ResponseOkCV).value; - const amount = 1000000; - const fee = amount * 0.1; // 10% of amount + const amount = 50000; + const fee = 5000; // 10% of amount const mySignature = generateDepositSignature( address1PK, @@ -271,7 +460,7 @@ describe("reservoir", () => { address1, reservoirContract, 1000000, - 1000000, + 50000, 1, reservoirContract ); @@ -281,7 +470,7 @@ describe("reservoir", () => { null, reservoirContract, address1, - 1000000, + 50000, 1000000, 1, reservoirContract @@ -296,7 +485,7 @@ describe("reservoir", () => { Cl.uint(fee), Cl.none(), Cl.uint(1000000), - Cl.uint(1000000), + Cl.uint(50000), Cl.buffer(mySignature), Cl.buffer(reservoirSignature), Cl.uint(1), @@ -314,9 +503,11 @@ describe("reservoir", () => { // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(1000000n + BigInt(fee)); + // 500000 - 50000 (borrowed) + 5000 (fee) + expect(reservoirBalance).toBe(455000n); const tapBalance = stxBalances.get(stackflowContract); - expect(tapBalance).toBe(2000000n); + // 1000000 (initial) + 50000 (borrowed) + expect(tapBalance).toBe(1050000n); // Verify the pipe const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); @@ -337,7 +528,7 @@ describe("reservoir", () => { ), "pending-2": Cl.some( Cl.tuple({ - amount: Cl.uint(1000000), + amount: Cl.uint(50000), "burn-height": Cl.uint( simnet.burnBlockHeight + CONFIRMATION_DEPTH ), diff --git a/tests/utils.ts b/tests/utils.ts index ef08320..75fd6f7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -80,6 +80,8 @@ export enum ReservoirError { NotInitialized = 206, UnapprovedToken = 207, IncorrectStackflow = 208, + AmountTooLow = 209, + LiquidityPoolFull = 210, } const structuredDataPrefix = Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); From e696138d806c99a6e4132125ff881ca014f77b3f Mon Sep 17 00:00:00 2001 From: obycode Date: Sun, 1 Jun 2025 10:35:45 -0400 Subject: [PATCH 12/78] chore: update to stacks.js v7 --- package-lock.json | 68 +++++++++++++++++++---------------------------- package.json | 4 +-- tests/utils.ts | 39 ++++++++++++--------------- 3 files changed, 47 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8b46b01..19579af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@hirosystems/clarinet-sdk": "^2.3.2", - "@stacks/transactions": "^6.12.0", + "@hirosystems/clarinet-sdk": "^3.0.2", + "@stacks/transactions": "^7.0.6", "chokidar-cli": "^3.0.0", "typescript": "^5.3.3", "vite": "^6.2.6", @@ -419,29 +419,26 @@ } }, "node_modules/@hirosystems/clarinet-sdk": { - "version": "2.15.2", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk/-/clarinet-sdk-2.15.2.tgz", - "integrity": "sha512-SPWKYSWI+mvFlZAjzd+cEzAKkHUi30zljA7pxcNupW8z9OszEfYRK13XrfMiZID0cwD5FhH/EyTm/49a8wYRSw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk/-/clarinet-sdk-3.0.2.tgz", + "integrity": "sha512-qWw7qYUu4e+IL5BNXP7ibP+6NIZ3UoxcT5ZhZvOMOYmLE/JPWKWBfHvzqAADywpmfYgUIUtcDOYW3rZEvZ72Hg==", "license": "GPL-3.0", "dependencies": { - "@hirosystems/clarinet-sdk-wasm": "2.15.1", - "@stacks/transactions": "^6.13.0", + "@hirosystems/clarinet-sdk-wasm": "3.0.2", + "@stacks/transactions": "^7.0.6", "kolorist": "^1.8.0", "prompts": "^2.4.2", "vitest": "^3.0.5", "yargs": "^17.7.2" }, - "bin": { - "clarinet-sdk": "dist/cjs/node/src/bin/index.js" - }, "engines": { "node": ">=18.0.0" } }, "node_modules/@hirosystems/clarinet-sdk-wasm": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-2.15.1.tgz", - "integrity": "sha512-yD/IO9CP/sPBOthkqySa25BoSdZdiLviM5A+odC248fFg51IgPiGGTG1pSzDKaHZgSQTIRffllhftWh2fWkq9g==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.0.2.tgz", + "integrity": "sha512-IrLVaJWTg+qljBE4L30bhoRlg1uMHXVAdlKlXWFjLwsQw8RFT68lpRSpfrgn0Ut0NXkX/uIQH5VhMKFJGJlRQQ==", "license": "GPL-3.0" }, "node_modules/@jridgewell/sourcemap-codec": { @@ -722,48 +719,35 @@ ] }, "node_modules/@stacks/common": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", - "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", - "license": "MIT", - "dependencies": { - "@types/bn.js": "^5.1.0", - "@types/node": "^18.0.4" - } + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-7.0.2.tgz", + "integrity": "sha512-+RSecHdkxOtswmE4tDDoZlYEuULpnTQVeDIG5eZ32opK8cFxf4EugAcK9CsIsHx/Se1yTEaQ21WGATmJGK84lQ==", + "license": "MIT" }, "node_modules/@stacks/network": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", - "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.0.2.tgz", + "integrity": "sha512-XzHnoWqku/jRrTgMXhmh3c+I0O9vDH24KlhzGDZtBu+8CGGyHNPAZzGwvoUShonMXrXjEnfO9IYQwV5aJhfv6g==", "license": "MIT", "dependencies": { - "@stacks/common": "^6.16.0", + "@stacks/common": "^7.0.2", "cross-fetch": "^3.1.5" } }, "node_modules/@stacks/transactions": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.17.0.tgz", - "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.0.6.tgz", + "integrity": "sha512-qRGo4tNwOh+avUv/u4JGqqUWQ8xW/iUWtJV0o3BxpMyRxqDXmj+m+yeAEVYf9jRDouOo+NaWmwtRmWc0URZPdw==", "license": "MIT", "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", - "@stacks/common": "^6.16.0", - "@stacks/network": "^6.17.0", + "@stacks/common": "^7.0.2", + "@stacks/network": "^7.0.2", "c32check": "^2.0.0", "lodash.clonedeep": "^4.5.0" } }, - "node_modules/@types/bn.js": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.1.6.tgz", - "integrity": "sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -775,6 +759,8 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.75.tgz", "integrity": "sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1864,7 +1850,9 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/vite": { "version": "6.2.6", diff --git a/package.json b/package.json index 8dba025..e254a6d 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "author": "", "license": "ISC", "dependencies": { - "@hirosystems/clarinet-sdk": "^2.3.2", - "@stacks/transactions": "^6.12.0", + "@hirosystems/clarinet-sdk": "^3.0.2", + "@stacks/transactions": "^7.0.6", "chokidar-cli": "^3.0.0", "typescript": "^5.3.3", "vite": "^6.2.6", diff --git a/tests/utils.ts b/tests/utils.ts index 75fd6f7..3d4c438 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,10 +1,9 @@ import { Cl, ClarityValue, - createStacksPrivateKey, serializeCV, + serializeCVBytes, signWithKey, - StacksPrivateKey, } from "@stacks/transactions"; import { createHash } from "crypto"; @@ -16,18 +15,14 @@ export const address3 = accounts.get("wallet_3")!; export const stackflowContract = `${deployer}.stackflow`; export const reservoirContract = `${deployer}.reservoir`; -export const deployerPK = createStacksPrivateKey( - "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601" -); -export const address1PK = createStacksPrivateKey( - "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801" -); -export const address2PK = createStacksPrivateKey( - "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101" -); -export const address3PK = createStacksPrivateKey( - "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901" -); +export const deployerPK = + "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601"; +export const address1PK = + "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801"; +export const address2PK = + "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101"; +export const address3PK = + "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901"; export const WAITING_PERIOD = 144; export const MAX_HEIGHT = 340282366920938463463374607431768211455n; @@ -96,7 +91,7 @@ export function sha256(data: Buffer): Buffer { } function structuredDataHash(structuredData: ClarityValue): Buffer { - return sha256(Buffer.from(serializeCV(structuredData))); + return sha256(Buffer.from(serializeCVBytes(structuredData))); } const domainHash = structuredDataHash( @@ -115,16 +110,16 @@ export function structuredDataHashWithPrefix( } export function signStructuredData( - privateKey: StacksPrivateKey, + privateKey: string, structuredData: ClarityValue ): Buffer { const hash = structuredDataHashWithPrefix(structuredData); - const data = signWithKey(privateKey, hash.toString("hex")).data; + const data = signWithKey(privateKey, hash.toString("hex")); return Buffer.from(data.slice(2) + data.slice(0, 2), "hex"); } export function generatePipeSignature( - privateKey: StacksPrivateKey, + privateKey: string, token: [string, string] | null, myPrincipal: string, theirPrincipal: string, @@ -171,7 +166,7 @@ export function generatePipeSignature( } export function generateClosePipeSignature( - privateKey: StacksPrivateKey, + privateKey: string, token: [string, string] | null, myPrincipal: string, theirPrincipal: string, @@ -194,7 +189,7 @@ export function generateClosePipeSignature( } export function generateTransferSignature( - privateKey: StacksPrivateKey, + privateKey: string, token: [string, string] | null, myPrincipal: string, theirPrincipal: string, @@ -221,7 +216,7 @@ export function generateTransferSignature( } export function generateDepositSignature( - privateKey: StacksPrivateKey, + privateKey: string, token: [string, string] | null, myPrincipal: string, theirPrincipal: string, @@ -244,7 +239,7 @@ export function generateDepositSignature( } export function generateWithdrawSignature( - privateKey: StacksPrivateKey, + privateKey: string, token: [string, string] | null, myPrincipal: string, theirPrincipal: string, From 21f00774b7d652e203a9aef7e70d6af9fa5cfd3e Mon Sep 17 00:00:00 2001 From: obycode Date: Sun, 1 Jun 2025 10:57:07 -0400 Subject: [PATCH 13/78] feat: dynamically adjust min liquidity --- contracts/reservoir.clar | 74 +++++++---- tests/reservoir.test.ts | 265 +++++++++++++++++++++++++++++++++------ tests/stackflow.test.ts | 1 - 3 files changed, 276 insertions(+), 64 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index cd26004..0452fb6 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -49,9 +49,9 @@ ;;; For example, 1000 = 10% (define-data-var borrow-rate uint u0) -;;; Current minimum amount for liquidity providers to add to the reservoir -;;; Initially set to 1,000 STX -(define-data-var min-liquidity-amount uint u1000000000) +;;; Absolute minimum amount for liquidity providers to add to the reservoir +;;; 1000 STX +(define-constant MIN_LIQUIDITY_FLOOR u1000000000) ;;; The token supported by this instance of the Reservoir contract. ;;; If `none`, only STX is supported. @@ -60,6 +60,9 @@ ;;; The StackFlow contract that this Reservoir is registered with. (define-data-var stackflow-contract (optional principal) none) +;;; Total liquidity in the Reservoir. +(define-data-var total-liquidity uint u0) + ;;; The list of providers funding the Reservoir. (define-data-var providers (list 256 principal) (list)) (define-constant MAX_PROVIDERS u256) @@ -194,10 +197,10 @@ ) ) -;; Set the borrow rate for the contract (in basis points). -;; Returns: -;; - `(ok true)` on success -;; - `ERR_UNAUTHORIZED` if the caller is not the operator +;;; Set the borrow rate for the contract (in basis points). +;;; Returns: +;;; - `(ok true)` on success +;;; - `ERR_UNAUTHORIZED` if the caller is not the operator (define-public (set-borrow-rate (new-rate uint)) (begin (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) @@ -206,17 +209,35 @@ ) ) -;; Calculate the fee for borrowing a given amount. -;; Returns the fee amount in the smallest unit of the token. +;;; Calculate the fee for borrowing a given amount. +;;; Returns the fee amount in the smallest unit of the token. (define-read-only (get-borrow-fee (amount uint)) (/ (* amount (var-get borrow-rate)) u10000) ) +;;; Get the minimum liquidity amount that providers must add to the reservoir. +;;; The minumum liquidity amount is based on the total liquidity in the +;;; reservoir and the number of providers, scaling up as we approach the +;;; maximum number of providers. +(define-read-only (get-min-liquidity) + (let ( + (total (var-get total-liquidity)) + (base (/ total MAX_PROVIDERS)) + (multiplier (+ u1 (log2 (+ (len (var-get providers)) u1)))) + (min-liquidity (* base multiplier)) + ) + (if (< min-liquidity MIN_LIQUIDITY_FLOOR) + MIN_LIQUIDITY_FLOOR + min-liquidity + ) + ) +) + ;;; As a provider, add `amount` of STX or FT `token` to the reservoir for -;;; borrowing. Providers must add at least min-liquidity-amount. +;;; borrowing. Providers must add at least the minimum liquidity amount. ;;; Returns: ;;; - `(ok true)` on success -;;; - `ERR_AMOUNT_TOO_LOW` if the amount is less than min-liquidity-amount +;;; - `ERR_AMOUNT_TOO_LOW` if the amount is less than minimum liquidity amount ;;; - `ERR_LIQUIDITY_POOL_FULL` if the maximum number of providers is reached ;;; - `ERR_FUNDING_FAILED` if the funding failed (define-public (add-liquidity @@ -225,7 +246,7 @@ ) (begin (try! (check-valid-token token)) - (asserts! (>= amount (var-get min-liquidity-amount)) ERR_AMOUNT_TOO_LOW) + (asserts! (>= amount (get-min-liquidity)) ERR_AMOUNT_TOO_LOW) (asserts! (< (len (var-get providers)) MAX_PROVIDERS) ERR_LIQUIDITY_POOL_FULL) (unwrap! (match token @@ -249,13 +270,17 @@ )) ) ) + + ;; Update the total liquidity in the reservoir. + (var-set total-liquidity (+ (var-get total-liquidity) amount)) + (ok true) ) ) ;;; As a liquidity provider, remove `amount` of STX or FT `token` from the -;;; reservoir. If this would leave the provider with less than -;;; `min-liquidity-amount`, then the full amount will be removed. +;;; reservoir. If this would leave the provider with less than the minimum +;;; liquidity amount, then the full amount will be removed. ;;; Returns: ;;; - `(ok uint)` on success, where `uint` is the amount removed ;;; - `ERR_UNAUTHORIZED` if the caller is not a liquidity provider @@ -268,7 +293,7 @@ (provider tx-sender) (provider-liquidity (default-to u0 (map-get? liquidity provider))) (adjusted-amount (if (and (<= amount provider-liquidity) - (< (- provider-liquidity amount) (var-get min-liquidity-amount))) + (< (- provider-liquidity amount) (get-min-liquidity))) provider-liquidity amount )) @@ -300,30 +325,25 @@ )) ERR_TRANSFER_FAILED ) + + ;; Update the total liquidity in the reservoir. + (var-set total-liquidity (- (var-get total-liquidity) adjusted-amount)) + (ok adjusted-amount) ) ) -;; Filter function to remove a provider from the list +;;; Filter function to remove a provider from the list (define-private (remove-provider (p principal)) (not (is-eq p tx-sender)) ) -;; Set the minimum liquidity amount -(define-public (set-min-liquidity-amount (amount uint)) - (begin - (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) - (asserts! (var-get initialized) ERR_NOT_INITIALIZED) - (ok (var-set min-liquidity-amount amount)) - ) -) - -;; Get the total liquidity in the reservoir +;;; Get the total liquidity in the reservoir (define-read-only (get-total-liquidity) (fold + (map get-provider-liquidity (var-get providers)) u0) ) -;; Get the liquidity for a provider +;;; Get the liquidity for a provider (define-private (get-provider-liquidity (provider principal)) (default-to u0 (map-get? liquidity provider)) ) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 6fb6e87..d832353 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { Cl, ClarityType, ResponseOkCV, UIntCV } from "@stacks/transactions"; +import { + Cl, + ClarityType, + getAddressFromPrivateKey, + makeRandomPrivKey, + ResponseOkCV, + UIntCV, +} from "@stacks/transactions"; import { deployer, address1, @@ -13,6 +20,8 @@ import { deployerPK, MAX_HEIGHT, CONFIRMATION_DEPTH, + address2, + accounts, } from "./utils"; describe("reservoir", () => { @@ -27,14 +36,6 @@ describe("reservoir", () => { [Cl.principal(stackflowContract), Cl.none(), Cl.uint(0)], deployer ); - - // Set minimum liquidity amount to a lower value for testing - simnet.callPublicFn( - "reservoir", - "set-min-liquidity-amount", - [Cl.uint(100000)], - deployer - ); }); describe("borrow rate", () => { @@ -141,6 +142,94 @@ describe("reservoir", () => { }); }); + describe("get-min-liquidity", () => { + it("returns correct floor minimum liquidity amount", () => { + const { result } = simnet.callReadOnlyFn( + "reservoir", + "get-min-liquidity", + [], + deployer + ); + expect(result).toBeUint(1000000000n); + }); + + it("increases as liquidity is added", () => { + // Add some liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(512000000000n)], + deployer + ); + + // Check new minimum liquidity + const { result } = simnet.callReadOnlyFn( + "reservoir", + "get-min-liquidity", + [], + deployer + ); + expect(result).toBeUint(4000000000n); + + // Add more liquidity (2 providers) + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(4000000000n)], + address1 + ); + const { result: result2 } = simnet.callReadOnlyFn( + "reservoir", + "get-min-liquidity", + [], + deployer + ); + expect(result2).toBeUint(4031250000n); + + // Add more liquidity (3 providers) + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(4031250000n)], + address2 + ); + const { result: result3 } = simnet.callReadOnlyFn( + "reservoir", + "get-min-liquidity", + [], + deployer + ); + expect(result3).toBeUint(6094116210n); + + // Add more liquidity (7 providers) + let prev_min = 6094116210n; + for (let i = 3; i < 8; i++) { + const address = accounts.get(`wallet_${i}`)!; + const { result } = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(prev_min)], + address + ); + expect(result).toBeOk(Cl.bool(true)); + + const amount = ( + simnet.callReadOnlyFn("reservoir", "get-min-liquidity", [], deployer) + .result as UIntCV + ).value as bigint; + expect(amount).toBeGreaterThan(prev_min); + prev_min = amount; + } + const { result: result7 } = simnet.callReadOnlyFn( + "reservoir", + "get-min-liquidity", + [], + deployer + ); + expect(result7).toBeUint(8646135668n); + }); + }); + describe("liquidity management", () => { it("provider can add STX liquidity", () => { const { result } = simnet.callPublicFn( @@ -155,6 +244,25 @@ describe("reservoir", () => { const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n); + + // Verify provider is added + const providers = simnet.getDataVar(reservoirContract, "providers"); + expect(providers).toBeList([Cl.principal(deployer)]); + + // Verify liquidity entry is created + const liquidity = simnet.getMapEntry( + reservoirContract, + "liquidity", + Cl.principal(deployer) + ); + expect(liquidity).toBeSome(Cl.uint(1000000000)); + + // Verify total-liquidity + const totalLiquidity = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidity).toBeUint(1000000000n); }); it("provider can remove their own STX liquidity", () => { @@ -162,7 +270,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(1000000000)], + [Cl.none(), Cl.uint(10000000000)], deployer ); @@ -178,7 +286,14 @@ describe("reservoir", () => { // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(999200000n); + expect(reservoirBalance).toBe(9999200000n); + + // Verify total-liquidity + const totalLiquidity = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidity).toBeUint(9999200000n); }); it("multiple providers can add liquidity", () => { @@ -203,6 +318,34 @@ describe("reservoir", () => { const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(3000000000n); + + // Verify providers + const providers = simnet.getDataVar(reservoirContract, "providers"); + expect(providers).toBeList([ + Cl.principal(deployer), + Cl.principal(address1), + ]); + + // Verify liquidity entries + const liquidityDeployer = simnet.getMapEntry( + reservoirContract, + "liquidity", + Cl.principal(deployer) + ); + expect(liquidityDeployer).toBeSome(Cl.uint(2000000000)); + const liquidityAddress1 = simnet.getMapEntry( + reservoirContract, + "liquidity", + Cl.principal(address1) + ); + expect(liquidityAddress1).toBeSome(Cl.uint(1000000000)); + + // Verify total-liquidity + const totalLiquidity = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidity).toBeUint(3000000000n); }); it("provider cannot remove more than they provided", () => { @@ -222,6 +365,30 @@ describe("reservoir", () => { deployer ); expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + + // Verify reservoir balance remains unchanged + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n); + + // Verify provider is still listed + const providers = simnet.getDataVar(reservoirContract, "providers"); + expect(providers).toBeList([Cl.principal(deployer)]); + + // Verify liquidity entry remains unchanged + const liquidity = simnet.getMapEntry( + reservoirContract, + "liquidity", + Cl.principal(deployer) + ); + expect(liquidity).toBeSome(Cl.uint(1000000000)); + + // Verify total-liquidity remains unchanged + const totalLiquidity = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidity).toBeUint(1000000000n); }); it("provider cannot remove below min-liquidity-amount", () => { @@ -229,7 +396,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(150000)], + [Cl.none(), Cl.uint(1500000000)], deployer ); @@ -237,11 +404,11 @@ describe("reservoir", () => { const { result } = simnet.callPublicFn( "reservoir", "remove-liquidity-from-reservoir", - [Cl.none(), Cl.uint(100000)], + [Cl.none(), Cl.uint(1000000000)], deployer ); // Should return the full remaining balance - expect(result).toBeOk(Cl.uint(150000)); + expect(result).toBeOk(Cl.uint(1500000000)); // Verify reservoir balance const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -259,6 +426,13 @@ describe("reservoir", () => { Cl.principal(deployer) ); expect(liquidity).toBeNone(); + + // Verify total-liquidity is now 0 + const totalLiquidity = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidity).toBeUint(0n); }); it("provider is removed when they withdraw all liquidity", () => { @@ -266,7 +440,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(200000)], + [Cl.none(), Cl.uint(2000000000)], deployer ); @@ -274,7 +448,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(500000)], + [Cl.none(), Cl.uint(5000000000)], address1 ); @@ -282,10 +456,10 @@ describe("reservoir", () => { const { result } = simnet.callPublicFn( "reservoir", "remove-liquidity-from-reservoir", - [Cl.none(), Cl.uint(200000)], + [Cl.none(), Cl.uint(2000000000)], deployer ); - expect(result).toBeOk(Cl.uint(200000)); + expect(result).toBeOk(Cl.uint(2000000000)); // Verify provider is removed const providers = simnet.getDataVar(reservoirContract, "providers"); @@ -299,12 +473,19 @@ describe("reservoir", () => { ); expect(liquidity).toBeNone(); + // Verify total-liquidity is now only from the second provider + const totalLiquidity = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidity).toBeUint(5000000000n); + // Second provider should now be the only one // Try a second provider to remove more than they have - should fail const result2 = simnet.callPublicFn( "reservoir", "remove-liquidity-from-reservoir", - [Cl.none(), Cl.uint(600000)], + [Cl.none(), Cl.uint(6000000000)], address1 ); expect(result2.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); @@ -313,10 +494,22 @@ describe("reservoir", () => { const result3 = simnet.callPublicFn( "reservoir", "remove-liquidity-from-reservoir", - [Cl.none(), Cl.uint(400000)], + [Cl.none(), Cl.uint(4000000000)], address1 ); - expect(result3.result).toBeOk(Cl.uint(400000)); + expect(result3.result).toBeOk(Cl.uint(4000000000)); + + // Verify reservoir balance after second provider's removal + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n); // 5000000000 - 4000000000 + + // Verify total-liquidity after second provider's removal + const totalLiquidityAfter = simnet.getDataVar( + reservoirContract, + "total-liquidity" + ); + expect(totalLiquidityAfter).toBeUint(1000000000n); }); it("non-provider cannot remove liquidity", () => { @@ -343,7 +536,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(200000)], + [Cl.none(), Cl.uint(2000000000)], deployer ); @@ -354,13 +547,13 @@ describe("reservoir", () => { [], deployer ); - expect(result1).toBeUint(200000); + expect(result1).toBeUint(2000000000); // Add liquidity from second provider simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(150000)], + [Cl.none(), Cl.uint(1500000000)], address1 ); @@ -371,13 +564,13 @@ describe("reservoir", () => { [], deployer ); - expect(result2).toBeUint(350000); + expect(result2).toBeUint(3500000000); // Remove some liquidity simnet.callPublicFn( "reservoir", "remove-liquidity-from-reservoir", - [Cl.none(), Cl.uint(50000)], + [Cl.none(), Cl.uint(500000000)], deployer ); @@ -388,7 +581,7 @@ describe("reservoir", () => { [], deployer ); - expect(result3).toBeUint(300000); + expect(result3).toBeUint(3000000000); }); }); @@ -432,7 +625,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(500000)], + [Cl.none(), Cl.uint(5000000000)], deployer ); @@ -503,8 +696,8 @@ describe("reservoir", () => { // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); - // 500000 - 50000 (borrowed) + 5000 (fee) - expect(reservoirBalance).toBe(455000n); + // 5000000000 - 50000 (borrowed) + 5000 (fee) + expect(reservoirBalance).toBe(4999955000n); const tapBalance = stxBalances.get(stackflowContract); // 1000000 (initial) + 50000 (borrowed) expect(tapBalance).toBe(1050000n); @@ -551,7 +744,7 @@ describe("reservoir", () => { simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(100000)], + [Cl.none(), Cl.uint(1000000000)], deployer ); @@ -569,8 +762,8 @@ describe("reservoir", () => { ); // Try to borrow more than available - const amount = 1000000; - const fee = 100000; + const amount = 10000000000; + const fee = 1000000000; const mySignature = generateDepositSignature( address1PK, @@ -578,7 +771,7 @@ describe("reservoir", () => { address1, reservoirContract, 50000, - 1000000, + amount, 1, address1 ); @@ -588,7 +781,7 @@ describe("reservoir", () => { null, reservoirContract, address1, - 1000000, + amount, 50000, 1, address1 @@ -603,7 +796,7 @@ describe("reservoir", () => { Cl.uint(fee), Cl.none(), Cl.uint(50000), - Cl.uint(1000000), + Cl.uint(amount), Cl.buffer(mySignature), Cl.buffer(reservoirSignature), Cl.uint(1), diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 54d846a..717850d 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -24,7 +24,6 @@ import { generateWithdrawSignature, address3PK, structuredDataHashWithPrefix, - sha256, } from "./utils"; describe("init", () => { From 3b4915dbae049f4b2c3b5a7f6333a9745bccb1bf Mon Sep 17 00:00:00 2001 From: obycode Date: Sun, 1 Jun 2025 23:13:33 -0400 Subject: [PATCH 14/78] feat: add `return-liquidity-to-reservoir` --- contracts/reservoir.clar | 52 +++++++++-- tests/reservoir.test.ts | 190 +++++++++++++++++++++++++++++++-------- 2 files changed, 198 insertions(+), 44 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 0452fb6..20dbe7f 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -73,6 +73,17 @@ uint ) +;;; Map tracking the borrowed liquidity for each tap holder. +(define-map borrowed-liquidity + principal + { + ;;; Amount borrowed + amount: uint, + ;;; Burn block height when the borrow expires + until: uint, + } +) + (define-public (init (stackflow ) (token (optional )) @@ -278,14 +289,14 @@ ) ) -;;; As a liquidity provider, remove `amount` of STX or FT `token` from the +;;; As a liquidity provider, withdraw `amount` of STX or FT `token` from the ;;; reservoir. If this would leave the provider with less than the minimum ;;; liquidity amount, then the full amount will be removed. ;;; Returns: ;;; - `(ok uint)` on success, where `uint` is the amount removed ;;; - `ERR_UNAUTHORIZED` if the caller is not a liquidity provider ;;; - `ERR_TRANSFER_FAILED` if the transfer failed -(define-public (remove-liquidity-from-reservoir +(define-public (withdraw-liquidity-from-reservoir (token (optional )) (amount uint) ) @@ -333,16 +344,43 @@ ) ) +;;; Return liquidity to the reservoir a withdrawal as the reservoir. Tap +;;; holders can call this function to return liquidity back to the reservoir +;;; after the reservoir's balance has reached a certain threshold. If the user +;;; does not perform this action in certain scenarios, the reservoir will +;;; refuse further transfers to/from the tap holder and eventually force-close +;;; the tap. +;;; Returns: +;;; - `(ok pipe-key)` on success +;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one +;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token +(define-public (return-liquidity-to-reservoir + (stackflow ) + (token (optional )) + (amount uint) + (my-balance uint) + (reservoir-balance uint) + (my-signature (buff 65)) + (reservoir-signature (buff 65)) + (nonce uint) + ) + (let ( + (tap-holder tx-sender) + ) + (try! (check-valid stackflow token)) + + (as-contract (contract-call? stackflow withdraw amount token tap-holder reservoir-balance + my-balance reservoir-signature my-signature nonce + )) + ) +) + ;;; Filter function to remove a provider from the list (define-private (remove-provider (p principal)) (not (is-eq p tx-sender)) ) -;;; Get the total liquidity in the reservoir -(define-read-only (get-total-liquidity) - (fold + (map get-provider-liquidity (var-get providers)) u0) -) - ;;; Get the liquidity for a provider (define-private (get-provider-liquidity (provider principal)) (default-to u0 (map-get? liquidity provider)) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index d832353..1e33407 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -1,12 +1,5 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { - Cl, - ClarityType, - getAddressFromPrivateKey, - makeRandomPrivKey, - ResponseOkCV, - UIntCV, -} from "@stacks/transactions"; +import { Cl, ClarityType, ResponseOkCV, UIntCV } from "@stacks/transactions"; import { deployer, address1, @@ -22,6 +15,7 @@ import { CONFIRMATION_DEPTH, address2, accounts, + generateWithdrawSignature, } from "./utils"; describe("reservoir", () => { @@ -277,7 +271,7 @@ describe("reservoir", () => { // Then remove some (leaving more than the minimum) const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(800000)], deployer ); @@ -360,7 +354,7 @@ describe("reservoir", () => { // Try to remove more than provided const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(2000000000)], deployer ); @@ -403,7 +397,7 @@ describe("reservoir", () => { // Try to leave less than min-liquidity-amount const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(1000000000)], deployer ); @@ -455,7 +449,7 @@ describe("reservoir", () => { // First provider removes all their liquidity const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(2000000000)], deployer ); @@ -484,7 +478,7 @@ describe("reservoir", () => { // Try a second provider to remove more than they have - should fail const result2 = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(6000000000)], address1 ); @@ -493,7 +487,7 @@ describe("reservoir", () => { // But they should be able to remove part of what they put in, leaving the minimum const result3 = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(4000000000)], address1 ); @@ -524,7 +518,7 @@ describe("reservoir", () => { // Try to remove as non-provider const { result } = simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(500000)], address1 ); @@ -541,13 +535,8 @@ describe("reservoir", () => { ); // Check total liquidity - let { result: result1 } = simnet.callReadOnlyFn( - "reservoir", - "get-total-liquidity", - [], - deployer - ); - expect(result1).toBeUint(2000000000); + let liquidity = simnet.getDataVar(reservoirContract, "total-liquidity"); + expect(liquidity).toBeUint(2000000000); // Add liquidity from second provider simnet.callPublicFn( @@ -558,30 +547,20 @@ describe("reservoir", () => { ); // Check updated total liquidity - const { result: result2 } = simnet.callReadOnlyFn( - "reservoir", - "get-total-liquidity", - [], - deployer - ); - expect(result2).toBeUint(3500000000); + liquidity = simnet.getDataVar(reservoirContract, "total-liquidity"); + expect(liquidity).toBeUint(3500000000); // Remove some liquidity simnet.callPublicFn( "reservoir", - "remove-liquidity-from-reservoir", + "withdraw-liquidity-from-reservoir", [Cl.none(), Cl.uint(500000000)], deployer ); // Check updated total liquidity after removal - const { result: result3 } = simnet.callReadOnlyFn( - "reservoir", - "get-total-liquidity", - [], - deployer - ); - expect(result3).toBeUint(3000000000); + liquidity = simnet.getDataVar(reservoirContract, "total-liquidity"); + expect(liquidity).toBeUint(3000000000); }); }); @@ -930,4 +909,141 @@ describe("reservoir", () => { expect(result).toBeErr(Cl.uint(StackflowError.InvalidSenderSignature)); }); }); + + describe("return-liquidity-to-reservoir", () => { + let pipeKey; + beforeEach(() => { + // Add liquidity to reservoir + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000000)], + deployer + ); + + // Create initial tap with some funds + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), // Initial balance + Cl.uint(0), + ], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + pipeKey = (result as ResponseOkCV).value; + + // Wait for the fund to confirm + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + }); + + it("can return liquidity to reservoir", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 50000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 50000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(reservoirContract), + }) + ); + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + // Generate signature for returning liquidity + const myReturnSignature = generateWithdrawSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 0, + 2, + reservoirContract + ); + const reservoirReturnSignature = generateWithdrawSignature( + deployerPK, + null, + reservoirContract, + address1, + 0, + 1000000, + 2, + reservoirContract + ); + + const returnLiquidity = simnet.callPublicFn( + "reservoir", + "return-liquidity-to-reservoir", + [ + Cl.principal(stackflowContract), + Cl.none(), // No token + Cl.uint(50000), // Amount to return + Cl.uint(1000000), // My balance + Cl.uint(0), // Reservoir balance + Cl.buffer(myReturnSignature), + Cl.buffer(reservoirReturnSignature), + Cl.uint(2), // Nonce + ], + address1 + ); + expect(returnLiquidity.result).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(reservoirContract), + }) + ); + + // Verify the tap balance after returning liquidity + const stxBalances = simnet.getAssetsMap().get("STX")!; + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(1000000n); + + // Verify the reservoir balance after returning liquidity + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n + 5000n); + }); + }); }); From fa913bcf0916a1e0cdb7c89f3841ff5b058537cb Mon Sep 17 00:00:00 2001 From: obycode Date: Sun, 8 Jun 2025 17:33:33 -0400 Subject: [PATCH 15/78] feat: add withdraw queue for liquidity withdraws --- contracts/reservoir.clar | 127 +++++++++- tests/reservoir.test.ts | 530 ++++++++++++++++++++++++++++++++++++++- tests/utils.ts | 3 + 3 files changed, 634 insertions(+), 26 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 20dbe7f..9ffd6e0 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -73,6 +73,12 @@ uint ) +;;; Queue of liquidity providers waiting to withdraw their liquidity. +(define-data-var withdraw-queue (list 256 { + provider: principal, + amount: uint, +}) (list)) + ;;; Map tracking the borrowed liquidity for each tap holder. (define-map borrowed-liquidity principal @@ -328,20 +334,108 @@ ) ) - ;; Transfer the funds - (unwrap! - (as-contract (match token - t (contract-call? t transfer adjusted-amount tx-sender provider none) - (stx-transfer? adjusted-amount tx-sender provider) - )) - ERR_TRANSFER_FAILED + ;; Add this withdrawal to the queue for processing. + (var-set withdraw-queue + (unwrap! (as-max-len? (append (var-get withdraw-queue) { + provider: provider, + amount: adjusted-amount, + }) u256) + ERR_LIQUIDITY_POOL_FULL + ) ) - ;; Update the total liquidity in the reservoir. - (var-set total-liquidity (- (var-get total-liquidity) adjusted-amount)) + ;; Process the withdrawal queue. + (let ( + (acc + (fold withdraw-liquidity-to-fold (var-get withdraw-queue) { + token: token, + remaining: (list), + }) + ) + (r (get remaining acc)) + ) + (var-set withdraw-queue r) + + ;; If the queue is empty, return the amount withdrawn. + (if (is-eq (len r) u0) + (ok (some adjusted-amount)) + ;; If there are still remaining withdrawals, return none. + (ok none) + ) + ) + ) +) + +;;; Process the withdrawal queue, attempting to withdraw liquidity for each +;;; provider in the queue. If a withdrawal fails, the provider is added back +;;; to the remaining list to try again later. +;;; Returns: +;;; - `(ok (list { provider: principal, amount: uint }))` on success, where +;;; the list contains the withdrawals left on the queue. +(define-private (process-withdrawals + (token (optional )) + ) + (let ( + (acc { + token: token, + remaining: (list), + }) + ) + ;; Process the withdrawal queue. + (let ( + (r (fold withdraw-liquidity-to-fold (var-get withdraw-queue) acc)) + ) + (var-set withdraw-queue (get remaining r)) + ;; Return the updated accumulator. + (get remaining r) + ) + ) +) + +(define-private (withdraw-liquidity-to-fold + (withdraw { + provider: principal, + amount: uint, + }) + (acc { + token: (optional ), + remaining: (list 256 { + provider: principal, + amount: uint, + }), + }) + ) + (if (is-ok (withdraw-liquidity-to (get token acc) (get provider withdraw) (get amount withdraw))) + ;; If successful, update the total liquidity in the reservoir. + (begin + (var-set total-liquidity + (- (var-get total-liquidity) (get amount withdraw)) + ) + acc + ) + ;; Else, add the provider back to the remaining list to try again later. + { + token: (get token acc), + remaining: (unwrap-panic (as-max-len? + (append (get remaining acc) { + provider: (get provider withdraw), + amount: (get amount withdraw), + }) + u256 + )), + } + ) +) - (ok adjusted-amount) +(define-private (withdraw-liquidity-to + (token (optional )) + (provider principal) + (amount uint) ) + (as-contract (match token + t (contract-call? t transfer amount tx-sender provider none) + (stx-transfer? amount tx-sender provider) + )) ) ;;; Return liquidity to the reservoir a withdrawal as the reservoir. Tap @@ -351,7 +445,7 @@ ;;; refuse further transfers to/from the tap holder and eventually force-close ;;; the tap. ;;; Returns: -;;; - `(ok pipe-key)` on success +;;; - `(ok true)` on success ;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized ;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one ;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token @@ -370,9 +464,16 @@ ) (try! (check-valid stackflow token)) - (as-contract (contract-call? stackflow withdraw amount token tap-holder reservoir-balance + (print { + topic: "return-liquidity-to-reservoir", + amount: amount, + }) + (try! (as-contract (contract-call? stackflow withdraw amount token tap-holder reservoir-balance my-balance reservoir-signature my-signature nonce - )) + ))) + + (process-withdrawals token) + (ok true) ) ) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 1e33407..d7cbd3e 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -1,8 +1,17 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { Cl, ClarityType, ResponseOkCV, UIntCV } from "@stacks/transactions"; +import { + Cl, + ClarityType, + ResponseOkCV, + UIntCV, + ListCV, +} from "@stacks/transactions"; import { deployer, address1, + address2, + address3, + address4, address1PK, address2PK, reservoirContract, @@ -13,7 +22,6 @@ import { deployerPK, MAX_HEIGHT, CONFIRMATION_DEPTH, - address2, accounts, generateWithdrawSignature, } from "./utils"; @@ -275,7 +283,7 @@ describe("reservoir", () => { [Cl.none(), Cl.uint(800000)], deployer ); - expect(result).toBeOk(Cl.uint(800000)); + expect(result).toBeOk(Cl.some(Cl.uint(800000))); // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -402,7 +410,7 @@ describe("reservoir", () => { deployer ); // Should return the full remaining balance - expect(result).toBeOk(Cl.uint(1500000000)); + expect(result).toBeOk(Cl.some(Cl.uint(1500000000))); // Verify reservoir balance const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -453,7 +461,7 @@ describe("reservoir", () => { [Cl.none(), Cl.uint(2000000000)], deployer ); - expect(result).toBeOk(Cl.uint(2000000000)); + expect(result).toBeOk(Cl.some(Cl.uint(2000000000))); // Verify provider is removed const providers = simnet.getDataVar(reservoirContract, "providers"); @@ -491,7 +499,7 @@ describe("reservoir", () => { [Cl.none(), Cl.uint(4000000000)], address1 ); - expect(result3.result).toBeOk(Cl.uint(4000000000)); + expect(result3.result).toBeOk(Cl.some(Cl.uint(4000000000))); // Verify reservoir balance after second provider's removal const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -1028,13 +1036,7 @@ describe("reservoir", () => { ], address1 ); - expect(returnLiquidity.result).toBeOk( - Cl.tuple({ - token: Cl.none(), - "principal-1": Cl.principal(address1), - "principal-2": Cl.principal(reservoirContract), - }) - ); + expect(returnLiquidity.result).toBeOk(Cl.bool(true)); // Verify the tap balance after returning liquidity const stxBalances = simnet.getAssetsMap().get("STX")!; @@ -1046,4 +1048,506 @@ describe("reservoir", () => { expect(reservoirBalance).toBe(1000000000n + 5000n); }); }); + + describe("withdraw-queue", () => { + const provider1 = address2; + const provider2 = address3; + const provider3 = address4; + + // Initialize contracts before tests + beforeEach(() => { + // Initialize stackflow contract + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + // Initialize reservoir contract with test token + simnet.callPublicFn( + "reservoir", + "init", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(100), // initial borrow rate (1%) + ], + deployer + ); + }); + + it("successfully adds a withdrawal to the queue", () => { + // First, providers need to add liquidity + // Add liquidity with provider1 + const addLiquidity1 = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [ + Cl.none(), + Cl.uint(1000000000), // 1000 tokens + ], + provider1 + ); + expect(addLiquidity1.result).toBeOk(Cl.bool(true)); + + // Fund initial tap + const createTap = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], + address1 + ); + expect(createTap.result.type).toBe(ClarityType.ResponseOk); + + const amount = 50000; + const fee = 5000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 50000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 50000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result.type).toBe(ClarityType.ResponseOk); + + // Request withdrawal of full amount from provider1, but it should be + // queued because the liquidity is not available. + const { result: withdraw1 } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity-from-reservoir", + [Cl.none(), Cl.uint(1000000000)], + provider1 + ); + expect(withdraw1).toBeOk(Cl.none()); + + // Check balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + // 1000000000 - 50000 (borrowed) + 5000 (fee) + expect(reservoirBalance).toBe(999955000n); + const tapBalance = stxBalances.get(stackflowContract); + // 1000000 (initial) + 50000 (borrowed) + expect(tapBalance).toBe(1050000n); + + // Check that the withdrawal request was added to the queue + const withdrawQueue = simnet.getDataVar( + reservoirContract, + "withdraw-queue" + ); + expect(withdrawQueue).toBeList([ + Cl.tuple({ + provider: Cl.principal(provider1), + amount: Cl.uint(1000000000), + }), + ]); + }); + + it("successfully processes a withdrawal from the queue", () => { + // First, providers need to add liquidity + // Add liquidity with provider1 + const addLiquidity1 = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [ + Cl.none(), + Cl.uint(1000000000), // 1000 tokens + ], + provider1 + ); + expect(addLiquidity1.result).toBeOk(Cl.bool(true)); + + // Fund initial tap + const createTap = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], + address1 + ); + expect(createTap.result.type).toBe(ClarityType.ResponseOk); + + const amount = 50000; + const fee = 5000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 50000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 50000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result.type).toBe(ClarityType.ResponseOk); + + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + // Request withdrawal of full amount from provider1, but it should be + // queued because the liquidity is not available. + const { result: withdraw1 } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity-from-reservoir", + [Cl.none(), Cl.uint(1000000000)], + provider1 + ); + expect(withdraw1).toBeOk(Cl.none()); + + // Return the borrowed liquidity to the reservoir + // Generate signature for returning liquidity + const myReturnSignature = generateWithdrawSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 0, + 2, + reservoirContract + ); + const reservoirReturnSignature = generateWithdrawSignature( + deployerPK, + null, + reservoirContract, + address1, + 0, + 1000000, + 2, + reservoirContract + ); + + const returnLiquidity = simnet.callPublicFn( + reservoirContract, + "return-liquidity-to-reservoir", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(50000), // Return the borrowed amount + Cl.uint(1000000), + Cl.uint(0), + Cl.buffer(myReturnSignature), + Cl.buffer(reservoirReturnSignature), + Cl.uint(2), + ], + address1 + ); + expect(returnLiquidity.result.type).toBe(ClarityType.ResponseOk); + + // Now try to process the withdrawal queue + const { result: processQueue } = simnet.callPrivateFn( + reservoirContract, + "process-withdrawals", + [Cl.none()], + deployer + ); + expect(processQueue).toBeList([]); + + // Check balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + // 5000 (fee) + expect(reservoirBalance).toBe(5000n); + const tapBalance = stxBalances.get(stackflowContract); + // 1000000 (initial) + expect(tapBalance).toBe(1000000n); + + // Check that the withdrawal request was removed from the queue + const withdrawQueue = simnet.getDataVar( + reservoirContract, + "withdraw-queue" + ); + expect(withdrawQueue).toBeList([]); + }); + + it("successfully queues multiple withdrawals", () => { + const stxBalancesInitial = simnet.getAssetsMap().get("STX")!; + const provider1BalanceInitial = stxBalancesInitial.get(provider1); + + // First, providers need to add liquidity + // Add liquidity with provider1 + const addLiquidity1 = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [ + Cl.none(), + Cl.uint(1_000_000_000), // 1000 tokens + ], + provider1 + ); + expect(addLiquidity1.result).toBeOk(Cl.bool(true)); + + const addLiquidity2 = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [ + Cl.none(), + Cl.uint(1_000_000_000), // 1000 tokens + ], + provider2 + ); + expect(addLiquidity2.result).toBeOk(Cl.bool(true)); + + const addLiquidity3 = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [ + Cl.none(), + Cl.uint(1_000_000_000), // 1000 tokens + ], + provider3 + ); + expect(addLiquidity3.result).toBeOk(Cl.bool(true)); + + // Reservoir balance should be 3,000 + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(3000000000n); + + // Fund initial tap + const createTap = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1_000_000), + Cl.uint(0), + ], + address1 + ); + expect(createTap.result.type).toBe(ClarityType.ResponseOk); + + const amount = 2_500_000_000; + const fee = 250_000_000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1_000_000, + amount, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount, + 1_000_000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1_000_000), + Cl.uint(amount), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result.type).toBe(ClarityType.ResponseOk); + + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + const stxBalances0 = simnet.getAssetsMap().get("STX")!; + const reservoirBalance0 = stxBalances0.get(reservoirContract); + const tapBalance0 = stxBalances0.get(stackflowContract); + expect(reservoirBalance0).toBe(750_000_000n); + expect(tapBalance0).toBe(2_501_000_000n); + + // Request withdrawal of full amount from provider1, but it should be + // queued because the liquidity is not available. + const { result: withdraw1 } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity-from-reservoir", + [Cl.none(), Cl.uint(1_000_000_000)], + provider1 + ); + expect(withdraw1).toBeOk(Cl.none()); + + // Request withdrawal of a partial amount from provider2, but it should be + // queued because there is a pending withdrawal in the queue that cannot be + // processed yet. + const { result: withdraw2 } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity-from-reservoir", + [Cl.none(), Cl.uint(500_000_000)], + provider2 + ); + expect(withdraw2).toBeOk(Cl.none()); + + // Request withdrawal of full amount from provider3, but it should be + // queued because there is a pending withdrawal in the queue that cannot be + // processed yet (and there is not enough liquidity). + const { result: withdraw3 } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity-from-reservoir", + [Cl.none(), Cl.uint(1_000_000_000)], + provider3 + ); + expect(withdraw3).toBeOk(Cl.none()); + + // Return 1,100 of the borrowed liquidity to the reservoir, enough to + // process the first withdrawal in the queue. + const myReturnSignature = generateWithdrawSignature( + address1PK, + null, + address1, + reservoirContract, + 1_000_000, + 1_400_000_000, + 2, + reservoirContract + ); + const reservoirReturnSignature = generateWithdrawSignature( + deployerPK, + null, + reservoirContract, + address1, + 1_400_000_000, + 1_000_000, + 2, + reservoirContract + ); + + const returnLiquidity = simnet.callPublicFn( + reservoirContract, + "return-liquidity-to-reservoir", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1_100_000_000), + Cl.uint(1_000_000), + Cl.uint(1_400_000_000), + Cl.buffer(myReturnSignature), + Cl.buffer(reservoirReturnSignature), + Cl.uint(2), + ], + address1 + ); + expect(returnLiquidity.result.type).toBe(ClarityType.ResponseOk); + + // Check that this completed the first withdrawal in the queue + const stxBalances1 = simnet.getAssetsMap().get("STX")!; + const reservoirBalance1 = stxBalances1.get(reservoirContract); + // 750 (previously) + 1,100 (returned) - 1,000 (withdrawn) + expect(reservoirBalance1).toBe(850_000_000n); + const tapBalance1 = stxBalances1.get(stackflowContract); + // 2,501,000,000 (initial) - 1,100,000,000 (returned) + expect(tapBalance1).toBe(1_401_000_000n); + const provider1Balance = stxBalances1.get(provider1); + expect(provider1Balance).toBe(provider1BalanceInitial); + + // Check that the withdrawal request was removed from the queue + const withdrawQueue1 = simnet.getDataVar( + reservoirContract, + "withdraw-queue" + ); + expect(withdrawQueue1.type).toBe(ClarityType.List); + expect((withdrawQueue1 as ListCV).value.length).toBe(2); + + // Now try to process the withdrawal queue + const { result: processQueue } = simnet.callPrivateFn( + reservoirContract, + "process-withdrawals", + [Cl.none()], + deployer + ); + expect(processQueue.type).toBe(ClarityType.List); + expect((processQueue as ListCV).value.length).toBe(2); + + // Check balances: should be unchanged from previous check + const stxBalances2 = simnet.getAssetsMap().get("STX")!; + const reservoirBalance2 = stxBalances2.get(reservoirContract); + expect(reservoirBalance2).toBe(850_000_000n); + const tapBalance2 = stxBalances2.get(stackflowContract); + expect(tapBalance2).toBe(1_401_000_000n); + + // Check that the withdrawal request was removed from the queue + const withdrawQueue = simnet.getDataVar( + reservoirContract, + "withdraw-queue" + ); + expect(withdrawQueue.type).toBe(ClarityType.List); + expect((withdrawQueue as ListCV).value.length).toBe(2); + }); + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 3d4c438..a5276d6 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -12,6 +12,7 @@ export const deployer = accounts.get("deployer")!; export const address1 = accounts.get("wallet_1")!; export const address2 = accounts.get("wallet_2")!; export const address3 = accounts.get("wallet_3")!; +export const address4 = accounts.get("wallet_4")!; export const stackflowContract = `${deployer}.stackflow`; export const reservoirContract = `${deployer}.reservoir`; @@ -23,6 +24,8 @@ export const address2PK = "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101"; export const address3PK = "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901"; +export const address4PK = + "f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701"; export const WAITING_PERIOD = 144; export const MAX_HEIGHT = 340282366920938463463374607431768211455n; From 4e79d46c3f280b792e88bad3177be1a69447a85c Mon Sep 17 00:00:00 2001 From: obycode Date: Fri, 13 Jun 2025 22:13:25 -0400 Subject: [PATCH 16/78] feat: track borrowed liquidity The Reservoir cannot withdraw liquidity while the user still has a borrow active. --- contracts/reservoir.clar | 48 ++++++++++---- tests/reservoir.test.ts | 131 +++++++++++++++++++++++++++++++++------ tests/utils.ts | 1 + 3 files changed, 150 insertions(+), 30 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 9ffd6e0..9500526 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -53,6 +53,9 @@ ;;; 1000 STX (define-constant MIN_LIQUIDITY_FLOOR u1000000000) +;;; Term length for borrowed liquidity in blocks (roughly 4 weeks). +(define-constant BORROW_TERM_BLOCKS u4000) + ;;; The token supported by this instance of the Reservoir contract. ;;; If `none`, only STX is supported. (define-data-var supported-token (optional principal) none) @@ -181,7 +184,7 @@ ;;; they obtained from the reservoir, confirming the resulting balances in the ;;; tap. ;;; Returns: -;;; -`(ok pipe-key)` on success +;;; -`(ok expire-block)` on success ;;; - `ERR_BORROW_FEE_PAYMENT_FAILED` if the fee payment failed ;;; - Errors passed through from the StackFlow `deposit` function (define-public (borrow-liquidity @@ -198,6 +201,7 @@ (let ( (borrower tx-sender) (expected-fee (get-borrow-fee amount)) + (until (+ burn-block-height BORROW_TERM_BLOCKS)) ) (try! (check-valid stackflow token)) (asserts! (>= fee expected-fee) ERR_INVALID_FEE) @@ -208,9 +212,17 @@ ) ERR_BORROW_FEE_PAYMENT_FAILED ) - (as-contract (contract-call? stackflow deposit amount token borrower reservoir-balance + (try! (as-contract (contract-call? stackflow deposit amount token borrower reservoir-balance my-balance reservoir-signature my-signature nonce - )) + ))) + + ;; Record the borrowed liquidity for the borrower. + (map-set borrowed-liquidity borrower { + amount: amount, + until: until, + }) + + (ok until) ) ) @@ -438,10 +450,10 @@ )) ) -;;; Return liquidity to the reservoir a withdrawal as the reservoir. Tap -;;; holders can call this function to return liquidity back to the reservoir -;;; after the reservoir's balance has reached a certain threshold. If the user -;;; does not perform this action in certain scenarios, the reservoir will +;;; Return liquidity to the reservoir via a withdrawal as the reservoir. The +;;; reservoir operator will request signatures from the tap holder when the +;;; reservoir's balance has reached a certain threshold. If the user fails to +;;; provide the needed signatures for this withdrawal, then the reservoir will ;;; refuse further transfers to/from the tap holder and eventually force-close ;;; the tap. ;;; Returns: @@ -452,24 +464,36 @@ (define-public (return-liquidity-to-reservoir (stackflow ) (token (optional )) + (user principal) (amount uint) - (my-balance uint) + (user-balance uint) (reservoir-balance uint) - (my-signature (buff 65)) + (user-signature (buff 65)) (reservoir-signature (buff 65)) (nonce uint) ) (let ( - (tap-holder tx-sender) + (borrow (default-to { + amount: u0, + until: u0, + } + (map-get? borrowed-liquidity user) + )) + (borrowed-amount (if (> burn-block-height (get until borrow)) + u0 + (get amount borrow) + )) ) (try! (check-valid stackflow token)) + ;; The reservoir cannot attempt to return liquidity that is still borrowed. + (asserts! (>= reservoir-balance borrowed-amount) ERR_UNAUTHORIZED) (print { topic: "return-liquidity-to-reservoir", amount: amount, }) - (try! (as-contract (contract-call? stackflow withdraw amount token tap-holder reservoir-balance - my-balance reservoir-signature my-signature nonce + (try! (as-contract (contract-call? stackflow withdraw amount token user reservoir-balance + user-balance reservoir-signature user-signature nonce ))) (process-withdrawals token) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index d7cbd3e..d655ea1 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -24,6 +24,7 @@ import { CONFIRMATION_DEPTH, accounts, generateWithdrawSignature, + BORROW_TERM_BLOCKS, } from "./utils"; describe("reservoir", () => { @@ -673,11 +674,7 @@ describe("reservoir", () => { address1 ); expect(borrow.result).toBeOk( - Cl.tuple({ - token: Cl.none(), - "principal-1": Cl.principal(address1), - "principal-2": Cl.principal(reservoirContract), - }) + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) ); // Verify balances @@ -919,7 +916,6 @@ describe("reservoir", () => { }); describe("return-liquidity-to-reservoir", () => { - let pipeKey; beforeEach(() => { // Add liquidity to reservoir simnet.callPublicFn( @@ -942,7 +938,6 @@ describe("reservoir", () => { address1 ); expect(result.type).toBe(ClarityType.ResponseOk); - pipeKey = (result as ResponseOkCV).value; // Wait for the fund to confirm simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); @@ -991,13 +986,9 @@ describe("reservoir", () => { address1 ); expect(borrow.result).toBeOk( - Cl.tuple({ - token: Cl.none(), - "principal-1": Cl.principal(address1), - "principal-2": Cl.principal(reservoirContract), - }) + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) ); - simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); // Generate signature for returning liquidity const myReturnSignature = generateWithdrawSignature( @@ -1027,6 +1018,7 @@ describe("reservoir", () => { [ Cl.principal(stackflowContract), Cl.none(), // No token + Cl.principal(address1), Cl.uint(50000), // Amount to return Cl.uint(1000000), // My balance Cl.uint(0), // Reservoir balance @@ -1034,7 +1026,7 @@ describe("reservoir", () => { Cl.buffer(reservoirReturnSignature), Cl.uint(2), // Nonce ], - address1 + deployer ); expect(returnLiquidity.result).toBeOk(Cl.bool(true)); @@ -1047,6 +1039,107 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n + 5000n); }); + + it("cannot return liquidity before borrow term ends", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 50000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 50000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + // Mine enough blocks for the deposit to be confirmed, but not enough for + // the borrow term to end + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + // Generate signature for returning liquidity + const myReturnSignature = generateWithdrawSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 0, + 2, + reservoirContract + ); + const reservoirReturnSignature = generateWithdrawSignature( + deployerPK, + null, + reservoirContract, + address1, + 0, + 1000000, + 2, + reservoirContract + ); + + const returnLiquidity = simnet.callPublicFn( + "reservoir", + "return-liquidity-to-reservoir", + [ + Cl.principal(stackflowContract), + Cl.none(), // No token + Cl.principal(address1), + Cl.uint(50000), // Amount to return + Cl.uint(1000000), // My balance + Cl.uint(0), // Reservoir balance + Cl.buffer(myReturnSignature), + Cl.buffer(reservoirReturnSignature), + Cl.uint(2), // Nonce + ], + deployer + ); + expect(returnLiquidity.result).toBeErr( + Cl.uint(ReservoirError.Unauthorized) + ); + + // Verify the tap balance after returning liquidity + const stxBalances = simnet.getAssetsMap().get("STX")!; + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(1000000n + 50000n); + + // Verify the reservoir balance after returning liquidity + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n - 50000n + 5000n); + }); }); describe("withdraw-queue", () => { @@ -1246,7 +1339,7 @@ describe("reservoir", () => { ); expect(borrow.result.type).toBe(ClarityType.ResponseOk); - simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); // Request withdrawal of full amount from provider1, but it should be // queued because the liquidity is not available. @@ -1287,6 +1380,7 @@ describe("reservoir", () => { [ Cl.principal(stackflowContract), Cl.none(), + Cl.principal(address1), Cl.uint(50000), // Return the borrowed amount Cl.uint(1000000), Cl.uint(0), @@ -1294,7 +1388,7 @@ describe("reservoir", () => { Cl.buffer(reservoirReturnSignature), Cl.uint(2), ], - address1 + deployer ); expect(returnLiquidity.result.type).toBe(ClarityType.ResponseOk); @@ -1425,7 +1519,7 @@ describe("reservoir", () => { ); expect(borrow.result.type).toBe(ClarityType.ResponseOk); - simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); const stxBalances0 = simnet.getAssetsMap().get("STX")!; const reservoirBalance0 = stxBalances0.get(reservoirContract); @@ -1494,6 +1588,7 @@ describe("reservoir", () => { [ Cl.principal(stackflowContract), Cl.none(), + Cl.principal(address1), Cl.uint(1_100_000_000), Cl.uint(1_000_000), Cl.uint(1_400_000_000), @@ -1501,7 +1596,7 @@ describe("reservoir", () => { Cl.buffer(reservoirReturnSignature), Cl.uint(2), ], - address1 + deployer ); expect(returnLiquidity.result.type).toBe(ClarityType.ResponseOk); diff --git a/tests/utils.ts b/tests/utils.ts index a5276d6..038e960 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -30,6 +30,7 @@ export const address4PK = export const WAITING_PERIOD = 144; export const MAX_HEIGHT = 340282366920938463463374607431768211455n; export const CONFIRMATION_DEPTH = 6; +export const BORROW_TERM_BLOCKS = 4000; export enum PipeAction { Close = 0, From dd963c9f1ee069d4f8648adcb603f79ea5fbeea5 Mon Sep 17 00:00:00 2001 From: obycode Date: Fri, 13 Jun 2025 22:54:37 -0400 Subject: [PATCH 17/78] feat: add ability for Reservoir to force-close/cancel taps --- contracts/reservoir.clar | 74 ++++++++ tests/reservoir.test.ts | 360 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 9500526..4922823 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -501,6 +501,80 @@ ) ) +;;; Force-cancel a tap with the specified user. This will close the pipe and +;;; return the last balances to the user and the reservoir. This should only +;;; be called by the operator of the reservoir when the tap holder has failed +;;; to provide the needed signatures for a withdrawal. +(define-public (force-cancel-tap + (stackflow ) + (token (optional )) + (user principal) + ) + (let ( + (borrow (default-to { + amount: u0, + until: u0, + } + (map-get? borrowed-liquidity user) + )) + (borrowed-amount (if (> burn-block-height (get until borrow)) + u0 + (get amount borrow) + )) + ) + (try! (check-valid stackflow token)) + + ;; The reservoir cannot attempt to force-cancel a tap that has borrowed liquidity. + (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) + + ;; Call the StackFlow contract to force-cancel the tap. + (as-contract (contract-call? stackflow force-cancel token user)) + ) +) + +;;; Force-close a tap with the specified user. This will close the pipe and +;;; return the signed balances to the user and the reservoir. This should only +;;; be called by the operator of the reservoir when the tap holder has failed +;;; to provide the needed signatures for a withdrawal. +(define-public (force-close-tap + (stackflow ) + (token (optional )) + (user principal) + (user-balance uint) + (reservoir-balance uint) + (user-signature (buff 65)) + (reservoir-signature (buff 65)) + (nonce uint) + (action uint) + (actor principal) + (secret (optional (buff 32))) + (valid-after (optional uint)) + ) + (let ( + (borrow (default-to { + amount: u0, + until: u0, + } + (map-get? borrowed-liquidity user) + )) + (borrowed-amount (if (> burn-block-height (get until borrow)) + u0 + (get amount borrow) + )) + ) + (try! (check-valid stackflow token)) + + ;; The reservoir cannot attempt to force-close a tap that has borrowed liquidity. + (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) + + ;; Call the StackFlow contract to force-close the tap. + (as-contract (contract-call? stackflow force-close token user reservoir-balance + user-balance reservoir-signature user-signature nonce action actor + secret valid-after + )) + ) +) + ;;; Filter function to remove a provider from the list (define-private (remove-provider (p principal)) (not (is-eq p tx-sender)) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index d655ea1..3f2ccfa 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -25,6 +25,8 @@ import { accounts, generateWithdrawSignature, BORROW_TERM_BLOCKS, + PipeAction, + generateTransferSignature, } from "./utils"; describe("reservoir", () => { @@ -1645,4 +1647,362 @@ describe("reservoir", () => { expect((withdrawQueue as ListCV).value.length).toBe(2); }); }); + + describe("force-closures", () => { + beforeEach(() => { + // Add liquidity to reservoir + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000000)], + deployer + ); + + // Create initial tap with some funds + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), // Initial balance + Cl.uint(0), + ], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + + // Wait for the fund to confirm + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + }); + + it("can force-cancel a tap with borrow-liquidity signatures", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + const userBalance = 1000000; + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance, + amount, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount, + userBalance, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); + + const forceClose = simnet.callPublicFn( + "reservoir", + "force-cancel-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), // No token + Cl.principal(address1), // User + ], + deployer + ); + expect(forceClose.result.type).toBe(ClarityType.ResponseOk); + }); + + it("can force-close a tap with transfer signatures", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + const userBalance = 1000000; + + const myDepositSignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance, + amount, + 1, + reservoirContract + ); + + const reservoirDepositSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount, + userBalance, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(myDepositSignature), + Cl.buffer(reservoirDepositSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); + + // Generate transfer signatures, to be used for force-close + const mySignature = generateTransferSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance + 100, + amount - 100, + 5, + address1 + ); + + const reservoirSignature = generateTransferSignature( + deployerPK, + null, + reservoirContract, + address1, + amount - 100, + userBalance + 100, + 5, + address1 + ); + + const forceClose = simnet.callPublicFn( + "reservoir", + "force-close-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), // No token + Cl.principal(address1), // User + Cl.uint(userBalance + 100), // User balance + Cl.uint(amount - 100), // Reservoir balance + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(5), // Nonce + Cl.uint(PipeAction.Transfer), // Action + Cl.principal(address1), // Actor + Cl.none(), // No secret + Cl.none(), // No valid-after + ], + deployer + ); + expect(forceClose.result.type).toBe(ClarityType.ResponseOk); + }); + + it("cannot force-cancel before borrow term ends", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 50000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 50000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + // Mine enough blocks for the deposit to be confirmed, but not enough for + // the borrow term to end + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + const forceCancel = simnet.callPublicFn( + "reservoir", + "force-cancel-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), // No token + Cl.principal(address1), + ], + deployer + ); + expect(forceCancel.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + + // Verify the tap balance after returning liquidity + const stxBalances = simnet.getAssetsMap().get("STX")!; + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(1000000n + 50000n); + + // Verify the reservoir balance after returning liquidity + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n - 50000n + 5000n); + }); + + it("cannot force-close before borrow term ends", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + const userBalance = 1000000; + + const myDepositSignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance, + amount, + 1, + reservoirContract + ); + + const reservoirDepositSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount, + userBalance, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(userBalance), + Cl.uint(amount), + Cl.buffer(myDepositSignature), + Cl.buffer(reservoirDepositSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + // Mine enough blocks for the deposit to be confirmed, but not enough for + // the borrow term to end + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + // Generate transfer signatures, to be used for force-close + const mySignature = generateTransferSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance + 100, + amount - 100, + 5, + address1 + ); + + const reservoirSignature = generateTransferSignature( + deployerPK, + null, + reservoirContract, + address1, + amount - 100, + userBalance + 100, + 5, + address1 + ); + + const forceClose = simnet.callPublicFn( + "reservoir", + "force-close-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), // No token + Cl.principal(address1), // User + Cl.uint(userBalance), // User balance + Cl.uint(amount), // Reservoir balance + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(5), // Nonce + Cl.uint(PipeAction.Transfer), // Action + Cl.principal(address1), // Actor + Cl.none(), // No secret + Cl.none(), // No valid-after + ], + deployer + ); + expect(forceClose.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + + // Verify the tap balance after returning liquidity + const stxBalances = simnet.getAssetsMap().get("STX")!; + const tapBalance = stxBalances.get(stackflowContract); + expect(tapBalance).toBe(1000000n + 50000n); + + // Verify the reservoir balance after returning liquidity + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n - 50000n + 5000n); + }); + }); }); From daf30928130abe1122aaf13999b24aad460fbb40 Mon Sep 17 00:00:00 2001 From: obycode Date: Fri, 13 Jun 2025 23:11:42 -0400 Subject: [PATCH 18/78] chore: update dependencies --- package-lock.json | 811 ++++++++++++++++++++++++++-------------------- 1 file changed, 463 insertions(+), 348 deletions(-) diff --git a/package-lock.json b/package-lock.json index 19579af..eccfeeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,9 +19,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", "cpu": [ "ppc64" ], @@ -35,9 +35,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", "cpu": [ "arm" ], @@ -51,9 +51,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", "cpu": [ "arm64" ], @@ -67,9 +67,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", "cpu": [ "x64" ], @@ -83,9 +83,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", "cpu": [ "arm64" ], @@ -131,9 +131,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", "cpu": [ "x64" ], @@ -147,9 +147,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", "cpu": [ "arm" ], @@ -163,9 +163,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", "cpu": [ "arm64" ], @@ -179,9 +179,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", "cpu": [ "ia32" ], @@ -195,9 +195,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", "cpu": [ "loong64" ], @@ -211,9 +211,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", "cpu": [ "mips64el" ], @@ -227,9 +227,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", "cpu": [ "ppc64" ], @@ -243,9 +243,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", "cpu": [ "riscv64" ], @@ -259,9 +259,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", "cpu": [ "s390x" ], @@ -275,9 +275,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", "cpu": [ "x64" ], @@ -291,9 +291,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", "cpu": [ "arm64" ], @@ -307,9 +307,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", "cpu": [ "x64" ], @@ -323,9 +323,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", "cpu": [ "arm64" ], @@ -339,9 +339,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", "cpu": [ "x64" ], @@ -355,9 +355,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", "cpu": [ "x64" ], @@ -371,9 +371,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", "cpu": [ "arm64" ], @@ -387,9 +387,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", "cpu": [ "ia32" ], @@ -403,9 +403,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", "cpu": [ "x64" ], @@ -419,16 +419,15 @@ } }, "node_modules/@hirosystems/clarinet-sdk": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk/-/clarinet-sdk-3.0.2.tgz", - "integrity": "sha512-qWw7qYUu4e+IL5BNXP7ibP+6NIZ3UoxcT5ZhZvOMOYmLE/JPWKWBfHvzqAADywpmfYgUIUtcDOYW3rZEvZ72Hg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk/-/clarinet-sdk-3.1.0.tgz", + "integrity": "sha512-eYaoxI/luH7xe0DeE3c1q66Cxqi0bBed3AOUbhL9wqWtW7leD9RG+wQFuL3fJAPq7wONq32C92DDGuvQo6ZNQA==", "license": "GPL-3.0", "dependencies": { - "@hirosystems/clarinet-sdk-wasm": "3.0.2", + "@hirosystems/clarinet-sdk-wasm": "^3.1.0", "@stacks/transactions": "^7.0.6", "kolorist": "^1.8.0", "prompts": "^2.4.2", - "vitest": "^3.0.5", "yargs": "^17.7.2" }, "engines": { @@ -436,9 +435,9 @@ } }, "node_modules/@hirosystems/clarinet-sdk-wasm": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.0.2.tgz", - "integrity": "sha512-IrLVaJWTg+qljBE4L30bhoRlg1uMHXVAdlKlXWFjLwsQw8RFT68lpRSpfrgn0Ut0NXkX/uIQH5VhMKFJGJlRQQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.1.0.tgz", + "integrity": "sha512-PEjH5bn2aA/TJciZI7rr8WMW29NHscFvjGw/67OEJbyjRaC0Z0GF8bWPChNAJixkuTYkKlo7VcjXMsFR7SksmA==", "license": "GPL-3.0" }, "node_modules/@jridgewell/sourcemap-codec": { @@ -472,9 +471,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", - "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", + "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", "cpu": [ "arm" ], @@ -485,9 +484,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", - "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", + "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", "cpu": [ "arm64" ], @@ -498,9 +497,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", - "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", + "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", "cpu": [ "arm64" ], @@ -511,9 +510,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", - "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", + "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", "cpu": [ "x64" ], @@ -524,9 +523,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", - "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", + "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", "cpu": [ "arm64" ], @@ -537,9 +536,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", - "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", + "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", "cpu": [ "x64" ], @@ -550,9 +549,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", - "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", + "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", "cpu": [ "arm" ], @@ -563,9 +562,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", - "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", + "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", "cpu": [ "arm" ], @@ -576,9 +575,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", - "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", + "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", "cpu": [ "arm64" ], @@ -589,9 +588,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", - "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", + "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", "cpu": [ "arm64" ], @@ -602,9 +601,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", - "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", + "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", "cpu": [ "loong64" ], @@ -615,9 +614,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", - "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", + "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", "cpu": [ "ppc64" ], @@ -628,9 +627,22 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", - "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", + "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", + "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", "cpu": [ "riscv64" ], @@ -641,9 +653,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", - "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", + "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", "cpu": [ "s390x" ], @@ -654,9 +666,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", - "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", + "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", "cpu": [ "x64" ], @@ -667,9 +679,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", - "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", + "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", "cpu": [ "x64" ], @@ -680,9 +692,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", - "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", + "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", "cpu": [ "arm64" ], @@ -693,9 +705,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", - "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", + "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", "cpu": [ "ia32" ], @@ -706,9 +718,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", - "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", + "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", "cpu": [ "x64" ], @@ -735,9 +747,9 @@ } }, "node_modules/@stacks/transactions": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.0.6.tgz", - "integrity": "sha512-qRGo4tNwOh+avUv/u4JGqqUWQ8xW/iUWtJV0o3BxpMyRxqDXmj+m+yeAEVYf9jRDouOo+NaWmwtRmWc0URZPdw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.1.0.tgz", + "integrity": "sha512-/4n5h+ka5N3mq16f1Zo0O0g2gyOYhaXFdGN8ifLz38NJmkjnCDXqi/ogB6NFNpSKGonyqyF5Vz1UPaQHwO8+IA==", "license": "MIT", "dependencies": { "@noble/hashes": "1.1.5", @@ -748,31 +760,36 @@ "lodash.clonedeep": "^4.5.0" } }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "18.19.75", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.75.tgz", - "integrity": "sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==", + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "undici-types": "~5.26.4" + "@types/deep-eql": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, "node_modules/@vitest/expect": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.1.tgz", - "integrity": "sha512-q/zjrW9lgynctNbwvFtQkGK9+vvHA5UzVi2V8APrp1C6fG6/MuYYkmlx4FubuqLycCeSdHD5aadWfua/Vr0EUA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz", + "integrity": "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==", "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.3", + "@vitest/utils": "3.2.3", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -781,12 +798,12 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.1.tgz", - "integrity": "sha512-bmpJJm7Y7i9BBELlLuuM1J1Q6EQ6K5Ye4wcyOpOMXMcePYKSIYlpcrCm4l/O6ja4VJA5G2aMJiuZkZdnxlC3SA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.3.tgz", + "integrity": "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==", "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", + "@vitest/spy": "3.2.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -795,7 +812,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -807,9 +824,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.1.tgz", - "integrity": "sha512-dg0CIzNx+hMMYfNmSqJlLSXEmnNhMswcn3sXO7Tpldr0LiGmg3eXdLLhwkv2ZqgHb/d5xg5F7ezNFRA1fA13yA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz", + "integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==", "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" @@ -819,25 +836,26 @@ } }, "node_modules/@vitest/runner": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.1.tgz", - "integrity": "sha512-X/d46qzJuEDO8ueyjtKfxffiXraPRfmYasoC4i5+mlLEJ10UvPb0XH5M9C3gWuxd7BAQhpK42cJgJtq53YnWVA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.3.tgz", + "integrity": "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==", "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", - "pathe": "^2.0.3" + "@vitest/utils": "3.2.3", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.1.tgz", - "integrity": "sha512-bByMwaVWe/+1WDf9exFxWWgAixelSdiwo2p33tpqIlM14vW7PRV5ppayVXtfycqze4Qhtwag5sVhX400MLBOOw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.3.tgz", + "integrity": "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.3", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -846,24 +864,24 @@ } }, "node_modules/@vitest/spy": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.1.tgz", - "integrity": "sha512-+EmrUOOXbKzLkTDwlsc/xrwOlPDXyVk3Z6P6K4oiCndxz7YLpp/0R0UsWVOKT0IXWjjBJuSMk6D27qipaupcvQ==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.3.tgz", + "integrity": "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==", "license": "MIT", "dependencies": { - "tinyspy": "^3.0.2" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.1.tgz", - "integrity": "sha512-1XIjflyaU2k3HMArJ50bwSh3wKWPD6Q47wz/NUSmRV0zNywPc4w79ARjg/i/aNINHwA+mIALhUVqD9/aUvZNgg==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.3.tgz", + "integrity": "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.3", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, @@ -880,6 +898,21 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -903,9 +936,9 @@ } }, "node_modules/base-x": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", - "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", "license": "MIT" }, "node_modules/binary-extensions": { @@ -1062,6 +1095,21 @@ "wrap-ansi": "^5.1.0" } }, + "node_modules/chokidar-cli/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/chokidar-cli/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, "node_modules/chokidar-cli/node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -1166,18 +1214,21 @@ } }, "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, "node_modules/cross-fetch": { @@ -1190,9 +1241,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1231,15 +1282,15 @@ "license": "MIT" }, "node_modules/es-module-lexer": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", - "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1249,31 +1300,31 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { @@ -1413,6 +1464,12 @@ "node": ">=0.12.0" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -1527,19 +1584,7 @@ "node": ">=0.10.0" } }, - "node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/p-locate/node_modules/p-limit": { + "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", @@ -1554,6 +1599,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -1606,9 +1663,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.5", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", + "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", "funding": [ { "type": "opencollective", @@ -1625,7 +1682,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1674,12 +1731,12 @@ "license": "ISC" }, "node_modules/rollup": { - "version": "4.34.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", - "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", + "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.7" }, "bin": { "rollup": "dist/bin/rollup" @@ -1689,25 +1746,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.6", - "@rollup/rollup-android-arm64": "4.34.6", - "@rollup/rollup-darwin-arm64": "4.34.6", - "@rollup/rollup-darwin-x64": "4.34.6", - "@rollup/rollup-freebsd-arm64": "4.34.6", - "@rollup/rollup-freebsd-x64": "4.34.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.6", - "@rollup/rollup-linux-arm-musleabihf": "4.34.6", - "@rollup/rollup-linux-arm64-gnu": "4.34.6", - "@rollup/rollup-linux-arm64-musl": "4.34.6", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.6", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.6", - "@rollup/rollup-linux-riscv64-gnu": "4.34.6", - "@rollup/rollup-linux-s390x-gnu": "4.34.6", - "@rollup/rollup-linux-x64-gnu": "4.34.6", - "@rollup/rollup-linux-x64-musl": "4.34.6", - "@rollup/rollup-win32-arm64-msvc": "4.34.6", - "@rollup/rollup-win32-ia32-msvc": "4.34.6", - "@rollup/rollup-win32-x64-msvc": "4.34.6", + "@rollup/rollup-android-arm-eabi": "4.43.0", + "@rollup/rollup-android-arm64": "4.43.0", + "@rollup/rollup-darwin-arm64": "4.43.0", + "@rollup/rollup-darwin-x64": "4.43.0", + "@rollup/rollup-freebsd-arm64": "4.43.0", + "@rollup/rollup-freebsd-x64": "4.43.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", + "@rollup/rollup-linux-arm-musleabihf": "4.43.0", + "@rollup/rollup-linux-arm64-gnu": "4.43.0", + "@rollup/rollup-linux-arm64-musl": "4.43.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-gnu": "4.43.0", + "@rollup/rollup-linux-riscv64-musl": "4.43.0", + "@rollup/rollup-linux-s390x-gnu": "4.43.0", + "@rollup/rollup-linux-x64-gnu": "4.43.0", + "@rollup/rollup-linux-x64-musl": "4.43.0", + "@rollup/rollup-win32-arm64-msvc": "4.43.0", + "@rollup/rollup-win32-ia32-msvc": "4.43.0", + "@rollup/rollup-win32-x64-msvc": "4.43.0", "fsevents": "~2.3.2" } }, @@ -1776,6 +1834,18 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", + "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1788,10 +1858,52 @@ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", - "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", + "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -1807,9 +1919,9 @@ } }, "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", + "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1834,9 +1946,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1846,23 +1958,18 @@ "node": ">=14.17" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/vite": { - "version": "6.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", - "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -1926,16 +2033,16 @@ } }, "node_modules/vite-node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.1.tgz", - "integrity": "sha512-V+IxPAE2FvXpTCHXyNem0M+gWm6J7eRyWPR6vYoG/Gl+IscNOjXzztUhimQgTxaAoUoj40Qqimaa0NLIOOAH4w==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.3.tgz", + "integrity": "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==", "license": "MIT", "dependencies": { "cac": "^6.7.14", - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" @@ -1947,31 +2054,60 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.1.tgz", - "integrity": "sha512-kiZc/IYmKICeBAZr9DQ5rT7/6bD9G7uqQEki4fxazi1jdVl2mWGzedtBs5s6llz59yQhVb7FFY2MbHzHCnT79Q==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.3.tgz", + "integrity": "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==", "license": "MIT", "dependencies": { - "@vitest/expect": "3.1.1", - "@vitest/mocker": "3.1.1", - "@vitest/pretty-format": "^3.1.1", - "@vitest/runner": "3.1.1", - "@vitest/snapshot": "3.1.1", - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.3", + "@vitest/mocker": "3.2.3", + "@vitest/pretty-format": "^3.2.3", + "@vitest/runner": "3.2.3", + "@vitest/snapshot": "3.2.3", + "@vitest/spy": "3.2.3", + "@vitest/utils": "3.2.3", "chai": "^5.2.0", - "debug": "^4.4.0", - "expect-type": "^1.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", - "std-env": "^3.8.1", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", - "tinypool": "^1.0.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.0", "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.1.1", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.3", "why-is-node-running": "^2.3.0" }, "bin": { @@ -1987,8 +2123,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.1.1", - "@vitest/ui": "3.1.1", + "@vitest/browser": "3.2.3", + "@vitest/ui": "3.2.3", "happy-dom": "*", "jsdom": "*" }, @@ -2026,6 +2162,18 @@ "vitest": "^1.0.0 || ^2.0.0 || ^3.0.0" } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -2081,39 +2229,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", From c70f839d21feba7e07cbacb936b30f1ba2eb0db4 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 10 Dec 2025 20:51:37 -0500 Subject: [PATCH 19/78] feat: upgrade to Clarity 4 --- Clarinet.toml | 17 +- contracts/reservoir.clar | 123 ++-- contracts/stackflow.clar | 48 +- deployments/default.simnet-plan.yaml | 36 +- package-lock.json | 904 ++++++++++++++------------- package.json | 4 +- vitest.config.js | 2 +- 7 files changed, 602 insertions(+), 532 deletions(-) diff --git a/Clarinet.toml b/Clarinet.toml index 16e7d86..44a6cc7 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -13,23 +13,24 @@ contract_id = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.sip-010-trait-ft-standa [contracts.reservoir] path = 'contracts/reservoir.clar' -clarity_version = 3 -epoch = 3.1 +clarity_version = 4 +epoch = "3.3" [contracts.stackflow] path = 'contracts/stackflow.clar' -clarity_version = 3 -epoch = 3.1 +clarity_version = 4 +epoch = "3.3" [contracts.stackflow-token] path = 'contracts/stackflow-token.clar' -clarity_version = 3 -epoch = 3.1 +clarity_version = 4 +epoch = "3.3" [contracts.test-token] path = 'contracts/test-token.clar' -clarity_version = 3 -epoch = 3.0 +clarity_version = 4 +epoch = "3.3" + [repl.analysis] passes = [] diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 4922823..877aa98 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -27,7 +27,7 @@ ;; (use-trait stackflow-token 'SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token) (define-constant OPERATOR tx-sender) -(define-constant RESERVOIR (as-contract tx-sender)) +(define-constant RESERVOIR current-contract) ;; Error code (define-constant ERR_BORROW_FEE_PAYMENT_FAILED (err u200)) @@ -109,7 +109,7 @@ (var-set stackflow-contract (some (contract-of stackflow))) ;; Authorize the operator as a StackFlow agent for this contract. - (try! (as-contract (contract-call? stackflow register-agent OPERATOR))) + (try! (as-contract? () (try! (contract-call? stackflow register-agent OPERATOR)))) ;; Set the initial borrow rate. (var-set borrow-rate initial-borrow-rate) @@ -212,9 +212,18 @@ ) ERR_BORROW_FEE_PAYMENT_FAILED ) - (try! (as-contract (contract-call? stackflow deposit amount token borrower reservoir-balance - my-balance reservoir-signature my-signature nonce - ))) + (try! (match token + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (try! (contract-call? stackflow deposit amount token borrower reservoir-balance + my-balance reservoir-signature my-signature nonce + )) + ) + (as-contract? ((with-stx amount)) + (try! (contract-call? stackflow deposit amount token borrower reservoir-balance + my-balance reservoir-signature my-signature nonce + )) + ) + )) ;; Record the borrowed liquidity for the borrower. (map-set borrowed-liquidity borrower { @@ -279,8 +288,8 @@ (asserts! (< (len (var-get providers)) MAX_PROVIDERS) ERR_LIQUIDITY_POOL_FULL) (unwrap! (match token - t (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) - (stx-transfer? amount tx-sender (as-contract tx-sender)) + t (contract-call? t transfer amount tx-sender current-contract none) + (stx-transfer? amount tx-sender current-contract) ) ERR_FUNDING_FAILED ) @@ -321,10 +330,12 @@ (let ( (provider tx-sender) (provider-liquidity (default-to u0 (map-get? liquidity provider))) - (adjusted-amount (if (and (<= amount provider-liquidity) - (< (- provider-liquidity amount) (get-min-liquidity))) - provider-liquidity - amount + (adjusted-amount (if (and + (<= amount provider-liquidity) + (< (- provider-liquidity amount) (get-min-liquidity)) + ) + provider-liquidity + amount )) ) (try! (check-valid-token token)) @@ -332,9 +343,7 @@ (asserts! (<= amount provider-liquidity) ERR_UNAUTHORIZED) ;; Update provider's liquidity - (let ( - (new-liquidity (- provider-liquidity adjusted-amount)) - ) + (let ((new-liquidity (- provider-liquidity adjusted-amount))) (if (is-eq new-liquidity u0) ;; If provider is removing all liquidity, remove them from the list (begin @@ -348,22 +357,23 @@ ;; Add this withdrawal to the queue for processing. (var-set withdraw-queue - (unwrap! (as-max-len? (append (var-get withdraw-queue) { - provider: provider, - amount: adjusted-amount, - }) u256) + (unwrap! + (as-max-len? + (append (var-get withdraw-queue) { + provider: provider, + amount: adjusted-amount, + }) + u256 + ) ERR_LIQUIDITY_POOL_FULL - ) - ) + )) ;; Process the withdrawal queue. (let ( - (acc - (fold withdraw-liquidity-to-fold (var-get withdraw-queue) { - token: token, - remaining: (list), - }) - ) + (acc (fold withdraw-liquidity-to-fold (var-get withdraw-queue) { + token: token, + remaining: (list), + })) (r (get remaining acc)) ) (var-set withdraw-queue r) @@ -384,19 +394,13 @@ ;;; Returns: ;;; - `(ok (list { provider: principal, amount: uint }))` on success, where ;;; the list contains the withdrawals left on the queue. -(define-private (process-withdrawals - (token (optional )) - ) - (let ( - (acc { - token: token, - remaining: (list), - }) - ) +(define-private (process-withdrawals (token (optional ))) + (let ((acc { + token: token, + remaining: (list), + })) ;; Process the withdrawal queue. - (let ( - (r (fold withdraw-liquidity-to-fold (var-get withdraw-queue) acc)) - ) + (let ((r (fold withdraw-liquidity-to-fold (var-get withdraw-queue) acc))) (var-set withdraw-queue (get remaining r)) ;; Return the updated accumulator. (get remaining r) @@ -417,7 +421,9 @@ }), }) ) - (if (is-ok (withdraw-liquidity-to (get token acc) (get provider withdraw) (get amount withdraw))) + (if (is-ok (withdraw-liquidity-to (get token acc) (get provider withdraw) + (get amount withdraw) + )) ;; If successful, update the total liquidity in the reservoir. (begin (var-set total-liquidity @@ -444,10 +450,14 @@ (provider principal) (amount uint) ) - (as-contract (match token - t (contract-call? t transfer amount tx-sender provider none) - (stx-transfer? amount tx-sender provider) - )) + (match token + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (try! (contract-call? t transfer amount tx-sender provider none)) + ) + (as-contract? ((with-stx amount)) + (try! (stx-transfer? amount tx-sender provider)) + ) + ) ) ;;; Return liquidity to the reservoir via a withdrawal as the reservoir. The @@ -492,9 +502,18 @@ topic: "return-liquidity-to-reservoir", amount: amount, }) - (try! (as-contract (contract-call? stackflow withdraw amount token user reservoir-balance - user-balance reservoir-signature user-signature nonce - ))) + (try! (match token + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (try! (contract-call? stackflow withdraw amount token user reservoir-balance + user-balance reservoir-signature user-signature nonce + )) + ) + (as-contract? ((with-stx amount)) + (try! (contract-call? stackflow withdraw amount token user reservoir-balance + user-balance reservoir-signature user-signature nonce + )) + ) + )) (process-withdrawals token) (ok true) @@ -528,7 +547,7 @@ (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) ;; Call the StackFlow contract to force-cancel the tap. - (as-contract (contract-call? stackflow force-cancel token user)) + (as-contract? () (try! (contract-call? stackflow force-cancel token user))) ) ) @@ -568,10 +587,12 @@ (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) ;; Call the StackFlow contract to force-close the tap. - (as-contract (contract-call? stackflow force-close token user reservoir-balance - user-balance reservoir-signature user-signature nonce action actor - secret valid-after - )) + (as-contract? () + (try! (contract-call? stackflow force-close token user reservoir-balance + user-balance reservoir-signature user-signature nonce action actor + secret valid-after + )) + ) ) ) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index d58cc46..27090e9 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -78,6 +78,7 @@ (define-constant ERR_PENDING (err u123)) (define-constant ERR_INVALID_BALANCES (err u124)) (define-constant ERR_INVALID_SIGNATURE (err u125)) +(define-constant ERR_ALLOWANCE_VIOLATION (err u126)) ;; Number of burn blocks to wait before considering an on-chain action finalized. (define-constant CONFIRMATION_DEPTH u6) @@ -197,7 +198,6 @@ (updated-pipe (try! (increase-sender-balance pipe-key pipe token amount))) (closer (get closer pipe)) ) - ;; If there was an existing pipe, the new nonce must be equal or greater (asserts! (>= (get nonce pipe) nonce) ERR_NONCE_TOO_LOW) @@ -265,7 +265,6 @@ }) (settled-pipe (settle-pending pipe-key pipe)) ) - ;; Cannot close a pipe while there is a pending deposit (asserts! (and @@ -444,7 +443,6 @@ nonce: nonce, }) ) - ;; Verify the signatures of the two parties. (try! (verify-signatures my-signature tx-sender their-signature with pipe-key balance-1 balance-2 nonce action actor secret valid-after @@ -612,11 +610,9 @@ (pipe-nonce (get nonce pipe)) (closer (get closer pipe)) (principal-1 (get principal-1 pipe-key)) - ;; Ensure that the balance of the caller is not less than the deposit ;; amount, since that would indicate an invalid deposit. (balance-ok (asserts! (>= my-balance amount) ERR_INVALID_BALANCES)) - ;; These are the balances that both parties have signed off on, including ;; the deposit amount. (balance-1 (if (is-eq tx-sender principal-1) @@ -627,9 +623,7 @@ their-balance my-balance )) - (settled-pipe (settle-pending pipe-key pipe)) - ;; If the new balance of the pipe is not equal to the sum of the ;; existing balances and the deposit amount, the deposit is invalid. ;; Previously pending balances are included in the calculation. @@ -648,7 +642,6 @@ )) ERR_INVALID_TOTAL_BALANCE )) - ;; These are the settled balances that actually exist in the pipe while ;; the deposit is pending. (pre-balance-1 (if (is-eq tx-sender principal-1) @@ -667,7 +660,6 @@ )) (- my-balance amount) )) - (updated-pipe (try! (increase-sender-balance pipe-key settled-pipe token amount))) (result-pipe (merge updated-pipe { balance-1: pre-balance-1, @@ -1080,11 +1072,10 @@ (settled-pipe (settle-pending pipe-key pipe)) ) (match token - t (unwrap! - (contract-call? t transfer amount tx-sender (as-contract tx-sender) none) + t (unwrap! (contract-call? t transfer amount tx-sender current-contract none) ERR_DEPOSIT_FAILED ) - (unwrap! (stx-transfer? amount tx-sender (as-contract tx-sender)) + (unwrap! (stx-transfer? amount tx-sender current-contract) ERR_DEPOSIT_FAILED ) ) @@ -1119,10 +1110,17 @@ (let ((sender tx-sender)) (unwrap! (match token - t (as-contract (contract-call? t transfer amount tx-sender sender none)) - (as-contract (stx-transfer? amount tx-sender sender)) + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (unwrap! + (contract-call? t transfer amount current-contract sender none) + ERR_WITHDRAWAL_FAILED + )) + (as-contract? ((with-stx amount)) + (unwrap! (stx-transfer? amount current-contract sender) + ERR_WITHDRAWAL_FAILED + )) ) - ERR_WITHDRAWAL_FAILED + ERR_ALLOWANCE_VIOLATION ) (ok true) ) @@ -1159,7 +1157,6 @@ my-balance )) ) - ;; Exit early if this is an attempt to self-dispute (asserts! (not (is-eq for closer)) ERR_SELF_DISPUTE) @@ -1268,14 +1265,19 @@ ;; Don't try to transfer 0, this will cause an error (ok (is-some token)) (begin - (match token - t (unwrap! - (as-contract (contract-call? t transfer amount tx-sender addr none)) - ERR_WITHDRAWAL_FAILED - ) - (unwrap! (as-contract (stx-transfer? amount tx-sender addr)) - ERR_WITHDRAWAL_FAILED + (unwrap! + (match token + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (unwrap! + (contract-call? t transfer amount current-contract addr none) + ERR_WITHDRAWAL_FAILED + )) + (as-contract? ((with-stx amount)) + (unwrap! (stx-transfer? amount current-contract addr) + ERR_WITHDRAWAL_FAILED + )) ) + ERR_ALLOWANCE_VIOLATION ) (ok (is-some token)) ) diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index e9b6c61..f72310c 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -45,16 +45,20 @@ genesis: balance: "100000000000000" sbtc-balance: "1000000000" contracts: + - genesis + - lockup + - bns + - cost-voting - costs - pox + - costs-2 - pox-2 + - costs-3 - pox-3 - pox-4 - - lockup - - costs-2 - - costs-3 - - cost-voting - - bns + - signers + - signers-voting + - costs-4 plan: batches: - id: 0 @@ -64,7 +68,7 @@ plan: emulated-sender: SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE path: "./.cache/requirements/SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.clar" clarity-version: 1 - epoch: "2.0" + epoch: "2.1" - id: 1 transactions: - emulated-contract-publish: @@ -72,27 +76,27 @@ plan: emulated-sender: ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J path: "./.cache/requirements/ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.sip-010-trait-ft-standard.clar" clarity-version: 3 - - emulated-contract-publish: - contract-name: test-token - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/test-token.clar - clarity-version: 3 - epoch: "3.0" + epoch: "3.1" - id: 2 transactions: - emulated-contract-publish: contract-name: stackflow-token emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM path: contracts/stackflow-token.clar - clarity-version: 3 + clarity-version: 4 - emulated-contract-publish: contract-name: stackflow emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM path: contracts/stackflow.clar - clarity-version: 3 + clarity-version: 4 - emulated-contract-publish: contract-name: reservoir emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM path: contracts/reservoir.clar - clarity-version: 3 - epoch: "3.1" + clarity-version: 4 + - emulated-contract-publish: + contract-name: test-token + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/test-token.clar + clarity-version: 4 + epoch: "3.3" diff --git a/package-lock.json b/package-lock.json index eccfeeb..7603d6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,19 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@hirosystems/clarinet-sdk": "^3.0.2", + "@stacks/clarinet-sdk": "^3.10.0", "@stacks/transactions": "^7.0.6", "chokidar-cli": "^3.0.0", "typescript": "^5.3.3", "vite": "^6.2.6", "vitest": "^3.1.1", - "vitest-environment-clarinet": "^2.0.0" + "vitest-environment-clarinet": "^3.0.2" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -35,9 +35,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -51,9 +51,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -67,9 +67,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -83,9 +83,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -99,9 +99,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -131,9 +131,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -147,9 +147,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -163,9 +163,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -179,9 +179,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -195,9 +195,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -211,9 +211,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -227,9 +227,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -243,9 +243,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -259,9 +259,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -275,9 +275,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -291,9 +291,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -307,9 +307,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -323,9 +323,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -339,9 +339,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -354,10 +354,26 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -371,9 +387,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -387,9 +403,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -403,9 +419,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -418,32 +434,10 @@ "node": ">=18" } }, - "node_modules/@hirosystems/clarinet-sdk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk/-/clarinet-sdk-3.1.0.tgz", - "integrity": "sha512-eYaoxI/luH7xe0DeE3c1q66Cxqi0bBed3AOUbhL9wqWtW7leD9RG+wQFuL3fJAPq7wONq32C92DDGuvQo6ZNQA==", - "license": "GPL-3.0", - "dependencies": { - "@hirosystems/clarinet-sdk-wasm": "^3.1.0", - "@stacks/transactions": "^7.0.6", - "kolorist": "^1.8.0", - "prompts": "^2.4.2", - "yargs": "^17.7.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@hirosystems/clarinet-sdk-wasm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@hirosystems/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.1.0.tgz", - "integrity": "sha512-PEjH5bn2aA/TJciZI7rr8WMW29NHscFvjGw/67OEJbyjRaC0Z0GF8bWPChNAJixkuTYkKlo7VcjXMsFR7SksmA==", - "license": "GPL-3.0" - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@noble/hashes": { @@ -471,9 +465,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", - "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "cpu": [ "arm" ], @@ -484,9 +478,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", - "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", "cpu": [ "arm64" ], @@ -497,9 +491,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", - "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", "cpu": [ "arm64" ], @@ -510,9 +504,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", - "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", "cpu": [ "x64" ], @@ -523,9 +517,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", - "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", "cpu": [ "arm64" ], @@ -536,9 +530,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", - "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", "cpu": [ "x64" ], @@ -549,9 +543,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", - "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", "cpu": [ "arm" ], @@ -562,9 +556,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", - "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", "cpu": [ "arm" ], @@ -575,9 +569,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", - "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", "cpu": [ "arm64" ], @@ -588,9 +582,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", - "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", "cpu": [ "arm64" ], @@ -600,10 +594,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", - "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", "cpu": [ "loong64" ], @@ -613,10 +607,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", - "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", "cpu": [ "ppc64" ], @@ -627,9 +621,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", - "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", "cpu": [ "riscv64" ], @@ -640,9 +634,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", - "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", "cpu": [ "riscv64" ], @@ -653,9 +647,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", - "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", "cpu": [ "s390x" ], @@ -666,9 +660,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", - "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", "cpu": [ "x64" ], @@ -679,9 +673,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", - "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", "cpu": [ "x64" ], @@ -691,10 +685,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", - "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", "cpu": [ "arm64" ], @@ -705,9 +712,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", - "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", "cpu": [ "ia32" ], @@ -717,10 +724,23 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", - "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", "cpu": [ "x64" ], @@ -730,6 +750,28 @@ "win32" ] }, + "node_modules/@stacks/clarinet-sdk": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk/-/clarinet-sdk-3.11.0.tgz", + "integrity": "sha512-oiZ+x9PibUg4N+CNGSdana/5WRPMll77CGPaiN3Jimcrg0jjYyAYn4Lt6WgYUxxuAj9uLR8JiM5/PetmFx3itw==", + "license": "GPL-3.0", + "dependencies": { + "@stacks/clarinet-sdk-wasm": "3.11.0", + "@stacks/transactions": "^7.0.6", + "kolorist": "^1.8.0", + "prompts": "^2.4.2", + "yargs": "^18.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stacks/clarinet-sdk-wasm": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.11.0.tgz", + "integrity": "sha512-zJ4AfHIlJl0yLbTlCYqJPI7Jz5FUC9d6HEHupcruHnP+G7LRm8m+Uag2CxpXDkO2A4c60xYbVPNW0KQws7hHbw==", + "license": "GPL-3.0" + }, "node_modules/@stacks/common": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@stacks/common/-/common-7.0.2.tgz", @@ -737,9 +779,9 @@ "license": "MIT" }, "node_modules/@stacks/network": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.0.2.tgz", - "integrity": "sha512-XzHnoWqku/jRrTgMXhmh3c+I0O9vDH24KlhzGDZtBu+8CGGyHNPAZzGwvoUShonMXrXjEnfO9IYQwV5aJhfv6g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.2.0.tgz", + "integrity": "sha512-AkLougCF2RLbK97TtISZxAhF3cE757XMXWOGKvEFWNauiQ5/bYyI9W5jZypG3yI/AyYIo04NKoFWWTnpJcn1iA==", "license": "MIT", "dependencies": { "@stacks/common": "^7.0.2", @@ -747,26 +789,27 @@ } }, "node_modules/@stacks/transactions": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.1.0.tgz", - "integrity": "sha512-/4n5h+ka5N3mq16f1Zo0O0g2gyOYhaXFdGN8ifLz38NJmkjnCDXqi/ogB6NFNpSKGonyqyF5Vz1UPaQHwO8+IA==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.3.0.tgz", + "integrity": "sha512-xtIktW0I0z+5VPnQM5ZfXpeTKKBY2XwqvhYZJdIGT5QQ3SvZpJVoP7aod2pms4IUfW53kTyCjyaNm6hFmgWOWw==", "license": "MIT", "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", "@stacks/common": "^7.0.2", - "@stacks/network": "^7.0.2", + "@stacks/network": "^7.2.0", "c32check": "^2.0.0", "lodash.clonedeep": "^4.5.0" } }, "node_modules/@types/chai": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", - "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "license": "MIT", "dependencies": { - "@types/deep-eql": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/deep-eql": { @@ -776,20 +819,20 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.3.tgz", - "integrity": "sha512-W2RH2TPWVHA1o7UmaFKISPvdicFJH+mjykctJFoAkUw+SPTJTGjUNdKscFBrqM7IPnCVu6zihtKYa7TkZS1dkQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.3", - "@vitest/utils": "3.2.3", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -798,12 +841,12 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.3.tgz", - "integrity": "sha512-cP6fIun+Zx8he4rbWvi+Oya6goKQDZK+Yq4hhlggwQBbrlOQ4qtZ+G4nxB6ZnzI9lyIb+JnvyiJnPC2AGbKSPA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.3", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -824,9 +867,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.3.tgz", - "integrity": "sha512-yFglXGkr9hW/yEXngO+IKMhP0jxyFw2/qys/CK4fFUZnSltD+MU7dVYGrH8rvPcK/O6feXQA+EU33gjaBBbAng==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "license": "MIT", "dependencies": { "tinyrainbow": "^2.0.0" @@ -836,12 +879,12 @@ } }, "node_modules/@vitest/runner": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.3.tgz", - "integrity": "sha512-83HWYisT3IpMaU9LN+VN+/nLHVBCSIUKJzGxC5RWUOsK1h3USg7ojL+UXQR3b4o4UBIWCYdD2fxuzM7PQQ1u8w==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.3", + "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" }, @@ -850,12 +893,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.3.tgz", - "integrity": "sha512-9gIVWx2+tysDqUmmM1L0hwadyumqssOL1r8KJipwLx5JVYyxvVRfxvMq7DaWbZZsCqZnu/dZedaZQh4iYTtneA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.3", + "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -864,9 +907,9 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.3.tgz", - "integrity": "sha512-JHu9Wl+7bf6FEejTCREy+DmgWe+rQKbK+y32C/k5f4TBIAlijhJbRBIRIOCEpVevgRsCQR2iHRUH2/qKVM/plw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "license": "MIT", "dependencies": { "tinyspy": "^4.0.3" @@ -876,13 +919,13 @@ } }, "node_modules/@vitest/utils": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.3.tgz", - "integrity": "sha512-4zFBCU5Pf+4Z6v+rwnZ1HU1yzOKKvDkMXZrymE2PBlbjKJRlrOxbvpfPSvJTGRIwGoahaOGvp+kbCoxifhzJ1Q==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.3", - "loupe": "^3.1.3", + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, "funding": { @@ -890,24 +933,24 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -997,9 +1040,9 @@ } }, "node_modules/chai": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", - "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "license": "MIT", "dependencies": { "assertion-error": "^2.0.1", @@ -1009,7 +1052,7 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/check-error": { @@ -1095,36 +1138,12 @@ "wrap-ansi": "^5.1.0" } }, - "node_modules/chokidar-cli/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/chokidar-cli/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, "node_modules/chokidar-cli/node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "license": "MIT" }, - "node_modules/chokidar-cli/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/chokidar-cli/node_modules/string-width": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", @@ -1200,35 +1219,32 @@ } }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "license": "MIT", "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "color-name": "1.1.3" } }, "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "license": "MIT" }, "node_modules/cross-fetch": { @@ -1241,9 +1257,9 @@ } }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1276,9 +1292,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, "node_modules/es-module-lexer": { @@ -1288,9 +1304,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1300,31 +1316,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/escalade": { @@ -1346,9 +1363,9 @@ } }, "node_modules/expect-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", - "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -1401,6 +1418,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1435,12 +1464,12 @@ } }, "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/is-glob": { @@ -1517,18 +1546,18 @@ "license": "MIT" }, "node_modules/loupe": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", - "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "license": "MIT" }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/ms": { @@ -1636,9 +1665,9 @@ "license": "MIT" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "license": "MIT", "engines": { "node": ">= 14.16" @@ -1663,9 +1692,9 @@ } }, "node_modules/postcss": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", - "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -1731,12 +1760,12 @@ "license": "ISC" }, "node_modules/rollup": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", - "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -1746,26 +1775,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.43.0", - "@rollup/rollup-android-arm64": "4.43.0", - "@rollup/rollup-darwin-arm64": "4.43.0", - "@rollup/rollup-darwin-x64": "4.43.0", - "@rollup/rollup-freebsd-arm64": "4.43.0", - "@rollup/rollup-freebsd-x64": "4.43.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", - "@rollup/rollup-linux-arm-musleabihf": "4.43.0", - "@rollup/rollup-linux-arm64-gnu": "4.43.0", - "@rollup/rollup-linux-arm64-musl": "4.43.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", - "@rollup/rollup-linux-riscv64-gnu": "4.43.0", - "@rollup/rollup-linux-riscv64-musl": "4.43.0", - "@rollup/rollup-linux-s390x-gnu": "4.43.0", - "@rollup/rollup-linux-x64-gnu": "4.43.0", - "@rollup/rollup-linux-x64-musl": "4.43.0", - "@rollup/rollup-win32-arm64-msvc": "4.43.0", - "@rollup/rollup-win32-ia32-msvc": "4.43.0", - "@rollup/rollup-win32-x64-msvc": "4.43.0", + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" } }, @@ -1803,41 +1834,47 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", - "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "license": "MIT" }, "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/strip-literal": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.0.0.tgz", - "integrity": "sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "license": "MIT", "dependencies": { "js-tokens": "^9.0.1" @@ -1859,13 +1896,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -1875,10 +1912,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -1889,9 +1929,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -1901,9 +1941,9 @@ } }, "node_modules/tinypool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.0.tgz", - "integrity": "sha512-7CotroY9a8DKsKprEy/a14aCCm8jYVmR7aFy4fpkZM8sdpNJbKkixuNjgM50yCmip2ezc8z4N7k3oe2+rfRJCQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "license": "MIT", "engines": { "node": "^18.0.0 || >=20.0.0" @@ -1919,9 +1959,9 @@ } }, "node_modules/tinyspy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.3.tgz", - "integrity": "sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1946,9 +1986,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1959,9 +1999,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -2033,9 +2073,9 @@ } }, "node_modules/vite-node": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.3.tgz", - "integrity": "sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "license": "MIT", "dependencies": { "cac": "^6.7.14", @@ -2055,10 +2095,13 @@ } }, "node_modules/vite/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -2069,9 +2112,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -2081,19 +2124,19 @@ } }, "node_modules/vitest": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.3.tgz", - "integrity": "sha512-E6U2ZFXe3N/t4f5BwUaVCKRLHqUpk1CBWeMh78UT4VaTPH/2dyvH6ALl29JTovEPu9dVKr/K/J4PkXgrMbw4Ww==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.3", - "@vitest/mocker": "3.2.3", - "@vitest/pretty-format": "^3.2.3", - "@vitest/runner": "3.2.3", - "@vitest/snapshot": "3.2.3", - "@vitest/spy": "3.2.3", - "@vitest/utils": "3.2.3", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", @@ -2104,10 +2147,10 @@ "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", - "tinypool": "^1.1.0", + "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.3", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { @@ -2123,8 +2166,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.3", - "@vitest/ui": "3.2.3", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -2153,19 +2196,19 @@ } }, "node_modules/vitest-environment-clarinet": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/vitest-environment-clarinet/-/vitest-environment-clarinet-2.3.0.tgz", - "integrity": "sha512-SZLrQZ9CFzTIes54a2Sw1ijOa718yyFIEAd38IbpfHZL2gY8nwM/IqKsjCrVCcrD9+/hO83V0fTJoFqZR1262Q==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vitest-environment-clarinet/-/vitest-environment-clarinet-3.0.2.tgz", + "integrity": "sha512-zuK2KTOw2iISG9nsxO1kH3FPHBQT3fe96NJkYUb5CZqsRKcMetTPxTYiF+Sw+Y4WjSPc9bWmv1hY4o8A49ci3w==", "license": "GPL-3.0", "peerDependencies": { - "@hirosystems/clarinet-sdk": ">=2.14.0", - "vitest": "^1.0.0 || ^2.0.0 || ^3.0.0" + "@stacks/clarinet-sdk": ">=3.8.1", + "vitest": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" } }, "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -2213,17 +2256,17 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -2239,30 +2282,29 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } } } diff --git a/package.json b/package.json index e254a6d..01f65eb 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,12 @@ "author": "", "license": "ISC", "dependencies": { - "@hirosystems/clarinet-sdk": "^3.0.2", + "@stacks/clarinet-sdk": "^3.10.0", "@stacks/transactions": "^7.0.6", "chokidar-cli": "^3.0.0", "typescript": "^5.3.3", "vite": "^6.2.6", "vitest": "^3.1.1", - "vitest-environment-clarinet": "^2.0.0" + "vitest-environment-clarinet": "^3.0.2" } } diff --git a/vitest.config.js b/vitest.config.js index c6a8506..8cab1e9 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -2,7 +2,7 @@ /// import { defineConfig } from "vite"; -import { vitestSetupFilePath, getClarinetVitestsArgv } from "@hirosystems/clarinet-sdk/vitest"; +import { vitestSetupFilePath, getClarinetVitestsArgv } from "@stacks/clarinet-sdk/vitest"; /* In this file, Vitest is configured so that it works seamlessly with Clarinet and the Simnet. From 731b94f37a9e274af3657852e22eccde93584fa9 Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 13 Dec 2025 16:52:51 -0500 Subject: [PATCH 20/78] feat: simplify receive liquidity In this new model, the Reservoir operator is the one and only provider of liquidity. It makes more sense to do this, since having other liquidity providers requires the LPs to trust the Reservoir, so if they are going to trust the Reservoir, then we may as well move the details off-chain and simplify the contract. --- contracts/reservoir.clar | 498 ++++++++--------------- tests/reservoir.test.ts | 844 +++------------------------------------ tests/utils.ts | 3 +- 3 files changed, 217 insertions(+), 1128 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 877aa98..c90d72d 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -39,8 +39,7 @@ (define-constant ERR_NOT_INITIALIZED (err u206)) (define-constant ERR_UNAPPROVED_TOKEN (err u207)) (define-constant ERR_INCORRECT_STACKFLOW (err u208)) -(define-constant ERR_AMOUNT_TOO_LOW (err u209)) -(define-constant ERR_LIQUIDITY_POOL_FULL (err u210)) +(define-constant ERR_AMOUNT_NOT_AVAILABLE (err u209)) ;;; Has this contract been initialized? (define-data-var initialized bool false) @@ -49,10 +48,6 @@ ;;; For example, 1000 = 10% (define-data-var borrow-rate uint u0) -;;; Absolute minimum amount for liquidity providers to add to the reservoir -;;; 1000 STX -(define-constant MIN_LIQUIDITY_FLOOR u1000000000) - ;;; Term length for borrowed liquidity in blocks (roughly 4 weeks). (define-constant BORROW_TERM_BLOCKS u4000) @@ -63,24 +58,8 @@ ;;; The StackFlow contract that this Reservoir is registered with. (define-data-var stackflow-contract (optional principal) none) -;;; Total liquidity in the Reservoir. -(define-data-var total-liquidity uint u0) - -;;; The list of providers funding the Reservoir. -(define-data-var providers (list 256 principal) (list)) -(define-constant MAX_PROVIDERS u256) - -;;; Map of the liquidity provided by each provider. -(define-map liquidity - principal - uint -) - -;;; Queue of liquidity providers waiting to withdraw their liquidity. -(define-data-var withdraw-queue (list 256 { - provider: principal, - amount: uint, -}) (list)) +;;; Available liquidity in the Reservoir. +(define-data-var available-liquidity uint u0) ;;; Map tracking the borrowed liquidity for each tap holder. (define-map borrowed-liquidity @@ -93,6 +72,10 @@ } ) +;; ----- Functions called by the Reservoir operator ----- + +;;; Initialize the Reservoir contract with the specified StackFlow contract, +;;; supported token, and initial borrow rate. (define-public (init (stackflow ) (token (optional )) @@ -119,6 +102,151 @@ ) ) +;;; Set the borrow rate for the contract (in basis points). +;;; Returns: +;;; - `(ok true)` on success +;;; - `ERR_UNAUTHORIZED` if the caller is not the operator +(define-public (set-borrow-rate (new-rate uint)) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (ok (var-set borrow-rate new-rate)) + ) +) + +;;; As the operator, add `amount` of STX or FT `token` to the reservoir for +;;; borrowing. Providers must add at least the minimum liquidity amount. +;;; Returns: +;;; - `(ok true)` on success +;;; - `ERR_FUNDING_FAILED` if the funding failed +(define-public (add-liquidity + (token (optional )) + (amount uint) + ) + (begin + (try! (check-valid-token token)) + (unwrap! + (match token + t (contract-call? t transfer amount tx-sender current-contract none) + (stx-transfer? amount tx-sender current-contract) + ) + ERR_FUNDING_FAILED + ) + + ;; Update the total liquidity in the reservoir. + (var-set available-liquidity (+ (var-get available-liquidity) amount)) + + (ok true) + ) +) + +;;; As the operator, withdraw `amount` of STX or FT `token` from the Reservoir +;;; to `recipient`. +;;; Returns: +;;; - `(ok uint)` on success, where `uint` is the amount removed +;;; - `ERR_UNAUTHORIZED` if the caller is not a liquidity provider +;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token +;;; - `ERR_AMOUNT_NOT_AVAILABLE` if the amount is greater than the available liquidity +;;; - `ERR_TRANSFER_FAILED` if the transfer failed +(define-public (withdraw-liquidity + (token (optional )) + (amount uint) + (recipient principal) + ) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (try! (check-valid-token token)) + (asserts! (<= amount (var-get available-liquidity)) ERR_AMOUNT_NOT_AVAILABLE) + + ;; Perform the withdrawal. + (unwrap! (withdraw-liquidity-to token amount recipient) ERR_TRANSFER_FAILED) + + ;; Update the total liquidity in the reservoir. + (var-set available-liquidity (- (var-get available-liquidity) amount)) + + (ok amount) + ) +) + +;;; Force-cancel a tap with the specified user. This will close the pipe and +;;; return the last balances to the user and the reservoir. This should only +;;; be called by the operator of the reservoir when the tap holder has failed +;;; to provide the needed signatures for a withdrawal. +(define-public (force-cancel-tap + (stackflow ) + (token (optional )) + (user principal) + ) + (let ( + (borrow (default-to { + amount: u0, + until: u0, + } + (map-get? borrowed-liquidity user) + )) + (borrowed-amount (if (> burn-block-height (get until borrow)) + u0 + (get amount borrow) + )) + ) + (try! (check-valid stackflow token)) + + ;; The reservoir cannot attempt to force-cancel a tap that has borrowed liquidity. + (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) + + ;; Call the StackFlow contract to force-cancel the tap. + (as-contract? () (try! (contract-call? stackflow force-cancel token user))) + ) +) + +;;; Force-close a tap with the specified user. This will close the pipe and +;;; return the signed balances to the user and the reservoir. This should only +;;; be called by the operator of the reservoir when the tap holder has failed +;;; to provide the needed signatures for a withdrawal. +(define-public (force-close-tap + (stackflow ) + (token (optional )) + (user principal) + (user-balance uint) + (reservoir-balance uint) + (user-signature (buff 65)) + (reservoir-signature (buff 65)) + (nonce uint) + (action uint) + (actor principal) + (secret (optional (buff 32))) + (valid-after (optional uint)) + ) + (let ( + (borrow (default-to { + amount: u0, + until: u0, + } + (map-get? borrowed-liquidity user) + )) + (borrowed-amount (if (> burn-block-height (get until borrow)) + u0 + (get amount borrow) + )) + ) + (try! (check-valid stackflow token)) + + ;; The reservoir cannot attempt to force-close a tap that has borrowed liquidity. + (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) + + ;; Call the StackFlow contract to force-close the tap. + (as-contract? () + (try! (contract-call? stackflow force-close token user reservoir-balance + user-balance reservoir-signature user-signature nonce action actor + secret valid-after + )) + ) + ) +) + +;; ----- Functions called by Tap holders ----- + ;;; Create a new tap for FT `token` (`none` indicates STX) and deposit ;;; `amount` funds into it. ;;; Returns: @@ -231,232 +359,10 @@ until: until, }) - (ok until) - ) -) - -;;; Set the borrow rate for the contract (in basis points). -;;; Returns: -;;; - `(ok true)` on success -;;; - `ERR_UNAUTHORIZED` if the caller is not the operator -(define-public (set-borrow-rate (new-rate uint)) - (begin - (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) - (asserts! (var-get initialized) ERR_NOT_INITIALIZED) - (ok (var-set borrow-rate new-rate)) - ) -) - -;;; Calculate the fee for borrowing a given amount. -;;; Returns the fee amount in the smallest unit of the token. -(define-read-only (get-borrow-fee (amount uint)) - (/ (* amount (var-get borrow-rate)) u10000) -) - -;;; Get the minimum liquidity amount that providers must add to the reservoir. -;;; The minumum liquidity amount is based on the total liquidity in the -;;; reservoir and the number of providers, scaling up as we approach the -;;; maximum number of providers. -(define-read-only (get-min-liquidity) - (let ( - (total (var-get total-liquidity)) - (base (/ total MAX_PROVIDERS)) - (multiplier (+ u1 (log2 (+ (len (var-get providers)) u1)))) - (min-liquidity (* base multiplier)) - ) - (if (< min-liquidity MIN_LIQUIDITY_FLOOR) - MIN_LIQUIDITY_FLOOR - min-liquidity - ) - ) -) - -;;; As a provider, add `amount` of STX or FT `token` to the reservoir for -;;; borrowing. Providers must add at least the minimum liquidity amount. -;;; Returns: -;;; - `(ok true)` on success -;;; - `ERR_AMOUNT_TOO_LOW` if the amount is less than minimum liquidity amount -;;; - `ERR_LIQUIDITY_POOL_FULL` if the maximum number of providers is reached -;;; - `ERR_FUNDING_FAILED` if the funding failed -(define-public (add-liquidity - (token (optional )) - (amount uint) - ) - (begin - (try! (check-valid-token token)) - (asserts! (>= amount (get-min-liquidity)) ERR_AMOUNT_TOO_LOW) - (asserts! (< (len (var-get providers)) MAX_PROVIDERS) ERR_LIQUIDITY_POOL_FULL) - (unwrap! - (match token - t (contract-call? t transfer amount tx-sender current-contract none) - (stx-transfer? amount tx-sender current-contract) - ) - ERR_FUNDING_FAILED - ) - - ;; Credit this provider with the amount of liquidity they added. - (match (map-get? liquidity tx-sender) - ;; If the provider already exists, update their liquidity. - current - (map-set liquidity tx-sender (+ current amount)) - ;; If the provider does not exist, add them to the list of providers. - (begin - (map-set liquidity tx-sender amount) - (var-set providers - (unwrap! (as-max-len? (append (var-get providers) tx-sender) u256) - ERR_LIQUIDITY_POOL_FULL - )) - ) - ) - ;; Update the total liquidity in the reservoir. - (var-set total-liquidity (+ (var-get total-liquidity) amount)) - - (ok true) - ) -) - -;;; As a liquidity provider, withdraw `amount` of STX or FT `token` from the -;;; reservoir. If this would leave the provider with less than the minimum -;;; liquidity amount, then the full amount will be removed. -;;; Returns: -;;; - `(ok uint)` on success, where `uint` is the amount removed -;;; - `ERR_UNAUTHORIZED` if the caller is not a liquidity provider -;;; - `ERR_TRANSFER_FAILED` if the transfer failed -(define-public (withdraw-liquidity-from-reservoir - (token (optional )) - (amount uint) - ) - (let ( - (provider tx-sender) - (provider-liquidity (default-to u0 (map-get? liquidity provider))) - (adjusted-amount (if (and - (<= amount provider-liquidity) - (< (- provider-liquidity amount) (get-min-liquidity)) - ) - provider-liquidity - amount - )) - ) - (try! (check-valid-token token)) - ;; Ensure the provider has enough liquidity. - (asserts! (<= amount provider-liquidity) ERR_UNAUTHORIZED) - - ;; Update provider's liquidity - (let ((new-liquidity (- provider-liquidity adjusted-amount))) - (if (is-eq new-liquidity u0) - ;; If provider is removing all liquidity, remove them from the list - (begin - (map-delete liquidity provider) - (var-set providers (filter remove-provider (var-get providers))) - ) - ;; Otherwise, just update their balance - (map-set liquidity provider new-liquidity) - ) - ) - - ;; Add this withdrawal to the queue for processing. - (var-set withdraw-queue - (unwrap! - (as-max-len? - (append (var-get withdraw-queue) { - provider: provider, - amount: adjusted-amount, - }) - u256 - ) - ERR_LIQUIDITY_POOL_FULL - )) - - ;; Process the withdrawal queue. - (let ( - (acc (fold withdraw-liquidity-to-fold (var-get withdraw-queue) { - token: token, - remaining: (list), - })) - (r (get remaining acc)) - ) - (var-set withdraw-queue r) - - ;; If the queue is empty, return the amount withdrawn. - (if (is-eq (len r) u0) - (ok (some adjusted-amount)) - ;; If there are still remaining withdrawals, return none. - (ok none) - ) - ) - ) -) - -;;; Process the withdrawal queue, attempting to withdraw liquidity for each -;;; provider in the queue. If a withdrawal fails, the provider is added back -;;; to the remaining list to try again later. -;;; Returns: -;;; - `(ok (list { provider: principal, amount: uint }))` on success, where -;;; the list contains the withdrawals left on the queue. -(define-private (process-withdrawals (token (optional ))) - (let ((acc { - token: token, - remaining: (list), - })) - ;; Process the withdrawal queue. - (let ((r (fold withdraw-liquidity-to-fold (var-get withdraw-queue) acc))) - (var-set withdraw-queue (get remaining r)) - ;; Return the updated accumulator. - (get remaining r) - ) - ) -) - -(define-private (withdraw-liquidity-to-fold - (withdraw { - provider: principal, - amount: uint, - }) - (acc { - token: (optional ), - remaining: (list 256 { - provider: principal, - amount: uint, - }), - }) - ) - (if (is-ok (withdraw-liquidity-to (get token acc) (get provider withdraw) - (get amount withdraw) - )) - ;; If successful, update the total liquidity in the reservoir. - (begin - (var-set total-liquidity - (- (var-get total-liquidity) (get amount withdraw)) - ) - acc - ) - ;; Else, add the provider back to the remaining list to try again later. - { - token: (get token acc), - remaining: (unwrap-panic (as-max-len? - (append (get remaining acc) { - provider: (get provider withdraw), - amount: (get amount withdraw), - }) - u256 - )), - } - ) -) + (var-set available-liquidity (- (var-get available-liquidity) amount)) -(define-private (withdraw-liquidity-to - (token (optional )) - (provider principal) - (amount uint) - ) - (match token - t (as-contract? ((with-ft (contract-of t) "*" amount)) - (try! (contract-call? t transfer amount tx-sender provider none)) - ) - (as-contract? ((with-stx amount)) - (try! (stx-transfer? amount tx-sender provider)) - ) + (ok until) ) ) @@ -503,109 +409,47 @@ amount: amount, }) (try! (match token - t (as-contract? ((with-ft (contract-of t) "*" amount)) + t (as-contract? () (try! (contract-call? stackflow withdraw amount token user reservoir-balance user-balance reservoir-signature user-signature nonce )) ) - (as-contract? ((with-stx amount)) + (as-contract? () (try! (contract-call? stackflow withdraw amount token user reservoir-balance user-balance reservoir-signature user-signature nonce )) ) )) - (process-withdrawals token) (ok true) ) ) -;;; Force-cancel a tap with the specified user. This will close the pipe and -;;; return the last balances to the user and the reservoir. This should only -;;; be called by the operator of the reservoir when the tap holder has failed -;;; to provide the needed signatures for a withdrawal. -(define-public (force-cancel-tap - (stackflow ) - (token (optional )) - (user principal) - ) - (let ( - (borrow (default-to { - amount: u0, - until: u0, - } - (map-get? borrowed-liquidity user) - )) - (borrowed-amount (if (> burn-block-height (get until borrow)) - u0 - (get amount borrow) - )) - ) - (try! (check-valid stackflow token)) - - ;; The reservoir cannot attempt to force-cancel a tap that has borrowed liquidity. - (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) +;; ----- Read-only functions ----- - ;; Call the StackFlow contract to force-cancel the tap. - (as-contract? () (try! (contract-call? stackflow force-cancel token user))) - ) +;;; Calculate the fee for borrowing a given amount. +;;; Returns the fee amount in the smallest unit of the token. +(define-read-only (get-borrow-fee (amount uint)) + (/ (* amount (var-get borrow-rate)) u10000) ) -;;; Force-close a tap with the specified user. This will close the pipe and -;;; return the signed balances to the user and the reservoir. This should only -;;; be called by the operator of the reservoir when the tap holder has failed -;;; to provide the needed signatures for a withdrawal. -(define-public (force-close-tap - (stackflow ) +;; ---- Private helper functions ----- + +(define-private (withdraw-liquidity-to (token (optional )) - (user principal) - (user-balance uint) - (reservoir-balance uint) - (user-signature (buff 65)) - (reservoir-signature (buff 65)) - (nonce uint) - (action uint) - (actor principal) - (secret (optional (buff 32))) - (valid-after (optional uint)) + (amount uint) + (recipient principal) ) - (let ( - (borrow (default-to { - amount: u0, - until: u0, - } - (map-get? borrowed-liquidity user) - )) - (borrowed-amount (if (> burn-block-height (get until borrow)) - u0 - (get amount borrow) - )) + (match token + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (try! (contract-call? t transfer amount tx-sender recipient none)) ) - (try! (check-valid stackflow token)) - - ;; The reservoir cannot attempt to force-close a tap that has borrowed liquidity. - (asserts! (is-eq borrowed-amount u0) ERR_UNAUTHORIZED) - - ;; Call the StackFlow contract to force-close the tap. - (as-contract? () - (try! (contract-call? stackflow force-close token user reservoir-balance - user-balance reservoir-signature user-signature nonce action actor - secret valid-after - )) + (as-contract? ((with-stx amount)) + (try! (stx-transfer? amount tx-sender recipient)) ) ) ) -;;; Filter function to remove a provider from the list -(define-private (remove-provider (p principal)) - (not (is-eq p tx-sender)) -) - -;;; Get the liquidity for a provider -(define-private (get-provider-liquidity (provider principal)) - (default-to u0 (map-get? liquidity provider)) -) - ;;; Given an optional trait, return an optional principal for the trait. (define-private (contract-of-optional (trait (optional ))) (match trait diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 3f2ccfa..e97b6c5 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -28,6 +28,7 @@ import { PipeAction, generateTransferSignature, } from "./utils"; +import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js"; describe("reservoir", () => { beforeEach(() => { @@ -147,96 +148,8 @@ describe("reservoir", () => { }); }); - describe("get-min-liquidity", () => { - it("returns correct floor minimum liquidity amount", () => { - const { result } = simnet.callReadOnlyFn( - "reservoir", - "get-min-liquidity", - [], - deployer - ); - expect(result).toBeUint(1000000000n); - }); - - it("increases as liquidity is added", () => { - // Add some liquidity - simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(512000000000n)], - deployer - ); - - // Check new minimum liquidity - const { result } = simnet.callReadOnlyFn( - "reservoir", - "get-min-liquidity", - [], - deployer - ); - expect(result).toBeUint(4000000000n); - - // Add more liquidity (2 providers) - simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(4000000000n)], - address1 - ); - const { result: result2 } = simnet.callReadOnlyFn( - "reservoir", - "get-min-liquidity", - [], - deployer - ); - expect(result2).toBeUint(4031250000n); - - // Add more liquidity (3 providers) - simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(4031250000n)], - address2 - ); - const { result: result3 } = simnet.callReadOnlyFn( - "reservoir", - "get-min-liquidity", - [], - deployer - ); - expect(result3).toBeUint(6094116210n); - - // Add more liquidity (7 providers) - let prev_min = 6094116210n; - for (let i = 3; i < 8; i++) { - const address = accounts.get(`wallet_${i}`)!; - const { result } = simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(prev_min)], - address - ); - expect(result).toBeOk(Cl.bool(true)); - - const amount = ( - simnet.callReadOnlyFn("reservoir", "get-min-liquidity", [], deployer) - .result as UIntCV - ).value as bigint; - expect(amount).toBeGreaterThan(prev_min); - prev_min = amount; - } - const { result: result7 } = simnet.callReadOnlyFn( - "reservoir", - "get-min-liquidity", - [], - deployer - ); - expect(result7).toBeUint(8646135668n); - }); - }); - describe("liquidity management", () => { - it("provider can add STX liquidity", () => { + it("operator can add STX liquidity", () => { const { result } = simnet.callPublicFn( "reservoir", "add-liquidity", @@ -250,27 +163,15 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n); - // Verify provider is added - const providers = simnet.getDataVar(reservoirContract, "providers"); - expect(providers).toBeList([Cl.principal(deployer)]); - - // Verify liquidity entry is created - const liquidity = simnet.getMapEntry( - reservoirContract, - "liquidity", - Cl.principal(deployer) - ); - expect(liquidity).toBeSome(Cl.uint(1000000000)); - - // Verify total-liquidity + // Verify available-liquidity const totalLiquidity = simnet.getDataVar( reservoirContract, - "total-liquidity" + "available-liquidity" ); expect(totalLiquidity).toBeUint(1000000000n); }); - it("provider can remove their own STX liquidity", () => { + it("operator can remove their own unused STX liquidity", () => { // First add liquidity simnet.callPublicFn( "reservoir", @@ -282,78 +183,57 @@ describe("reservoir", () => { // Then remove some (leaving more than the minimum) const { result } = simnet.callPublicFn( "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(800000)], + "withdraw-liquidity", + [Cl.none(), Cl.uint(800000), Cl.principal(deployer)], deployer ); - expect(result).toBeOk(Cl.some(Cl.uint(800000))); + expect(result).toBeOk(Cl.uint(800000)); // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(9999200000n); - // Verify total-liquidity + // Verify available-liquidity const totalLiquidity = simnet.getDataVar( reservoirContract, - "total-liquidity" + "available-liquidity" ); expect(totalLiquidity).toBeUint(9999200000n); }); - it("multiple providers can add liquidity", () => { - // Add liquidity from deployer + it("operator can remove their own unused STX liquidity to another address", () => { + // First add liquidity simnet.callPublicFn( "reservoir", "add-liquidity", - [Cl.none(), Cl.uint(2000000000)], + [Cl.none(), Cl.uint(10000000000)], deployer ); - // Add liquidity from address1 + // Then remove some (leaving more than the minimum) const { result } = simnet.callPublicFn( "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(1000000000)], - address1 + "withdraw-liquidity", + [Cl.none(), Cl.uint(800000), Cl.principal(address1)], + deployer ); - expect(result).toBeOk(Cl.bool(true)); + expect(result).toBeOk(Cl.uint(800000)); - // Verify reservoir balance (should be sum of both) + // Verify balances const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(3000000000n); - - // Verify providers - const providers = simnet.getDataVar(reservoirContract, "providers"); - expect(providers).toBeList([ - Cl.principal(deployer), - Cl.principal(address1), - ]); - - // Verify liquidity entries - const liquidityDeployer = simnet.getMapEntry( - reservoirContract, - "liquidity", - Cl.principal(deployer) - ); - expect(liquidityDeployer).toBeSome(Cl.uint(2000000000)); - const liquidityAddress1 = simnet.getMapEntry( - reservoirContract, - "liquidity", - Cl.principal(address1) - ); - expect(liquidityAddress1).toBeSome(Cl.uint(1000000000)); + expect(reservoirBalance).toBe(9999200000n); - // Verify total-liquidity + // Verify available-liquidity const totalLiquidity = simnet.getDataVar( reservoirContract, - "total-liquidity" + "available-liquidity" ); - expect(totalLiquidity).toBeUint(3000000000n); + expect(totalLiquidity).toBeUint(9999200000n); }); - it("provider cannot remove more than they provided", () => { + it("operator cannot remove more than the available liquidity", () => { // Add liquidity simnet.callPublicFn( "reservoir", @@ -362,162 +242,29 @@ describe("reservoir", () => { deployer ); - // Try to remove more than provided + // Try to remove more than available const { result } = simnet.callPublicFn( "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(2000000000)], + "withdraw-liquidity", + [Cl.none(), Cl.uint(2000000000), Cl.principal(deployer)], deployer ); - expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + expect(result).toBeErr(Cl.uint(ReservoirError.AmountNotAvailable)); // Verify reservoir balance remains unchanged const stxBalances = simnet.getAssetsMap().get("STX")!; const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n); - // Verify provider is still listed - const providers = simnet.getDataVar(reservoirContract, "providers"); - expect(providers).toBeList([Cl.principal(deployer)]); - - // Verify liquidity entry remains unchanged - const liquidity = simnet.getMapEntry( - reservoirContract, - "liquidity", - Cl.principal(deployer) - ); - expect(liquidity).toBeSome(Cl.uint(1000000000)); - - // Verify total-liquidity remains unchanged + // Verify available-liquidity remains unchanged const totalLiquidity = simnet.getDataVar( reservoirContract, - "total-liquidity" + "available-liquidity" ); expect(totalLiquidity).toBeUint(1000000000n); }); - it("provider cannot remove below min-liquidity-amount", () => { - // Add liquidity - simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(1500000000)], - deployer - ); - - // Try to leave less than min-liquidity-amount - const { result } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(1000000000)], - deployer - ); - // Should return the full remaining balance - expect(result).toBeOk(Cl.some(Cl.uint(1500000000))); - - // Verify reservoir balance - const stxBalances = simnet.getAssetsMap().get("STX")!; - const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(0n); - - // Verify provider is removed - const providers = simnet.getDataVar(reservoirContract, "providers"); - expect(providers).toBeList([]); - - // Verify liquidity entry is removed - const liquidity = simnet.getMapEntry( - reservoirContract, - "liquidity", - Cl.principal(deployer) - ); - expect(liquidity).toBeNone(); - - // Verify total-liquidity is now 0 - const totalLiquidity = simnet.getDataVar( - reservoirContract, - "total-liquidity" - ); - expect(totalLiquidity).toBeUint(0n); - }); - - it("provider is removed when they withdraw all liquidity", () => { - // Add liquidity - simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(2000000000)], - deployer - ); - - // Add more liquidity from another provider - simnet.callPublicFn( - "reservoir", - "add-liquidity", - [Cl.none(), Cl.uint(5000000000)], - address1 - ); - - // First provider removes all their liquidity - const { result } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(2000000000)], - deployer - ); - expect(result).toBeOk(Cl.some(Cl.uint(2000000000))); - - // Verify provider is removed - const providers = simnet.getDataVar(reservoirContract, "providers"); - expect(providers).toBeList([Cl.principal(address1)]); - - // Verify liquidity entry is removed - const liquidity = simnet.getMapEntry( - reservoirContract, - "liquidity", - Cl.principal(deployer) - ); - expect(liquidity).toBeNone(); - - // Verify total-liquidity is now only from the second provider - const totalLiquidity = simnet.getDataVar( - reservoirContract, - "total-liquidity" - ); - expect(totalLiquidity).toBeUint(5000000000n); - - // Second provider should now be the only one - // Try a second provider to remove more than they have - should fail - const result2 = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(6000000000)], - address1 - ); - expect(result2.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); - - // But they should be able to remove part of what they put in, leaving the minimum - const result3 = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(4000000000)], - address1 - ); - expect(result3.result).toBeOk(Cl.some(Cl.uint(4000000000))); - - // Verify reservoir balance after second provider's removal - const stxBalances = simnet.getAssetsMap().get("STX")!; - const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(1000000000n); // 5000000000 - 4000000000 - - // Verify total-liquidity after second provider's removal - const totalLiquidityAfter = simnet.getDataVar( - reservoirContract, - "total-liquidity" - ); - expect(totalLiquidityAfter).toBeUint(1000000000n); - }); - - it("non-provider cannot remove liquidity", () => { + it("non-operator cannot remove liquidity", () => { // First add liquidity as deployer simnet.callPublicFn( "reservoir", @@ -526,17 +273,17 @@ describe("reservoir", () => { deployer ); - // Try to remove as non-provider + // Try to remove as non-operator const { result } = simnet.callPublicFn( "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(500000)], + "withdraw-liquidity", + [Cl.none(), Cl.uint(500000), Cl.principal(address1)], address1 ); expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); }); - it("calculates total liquidity correctly", () => { + it("calculates available liquidity correctly", () => { // Add liquidity from first provider simnet.callPublicFn( "reservoir", @@ -546,31 +293,34 @@ describe("reservoir", () => { ); // Check total liquidity - let liquidity = simnet.getDataVar(reservoirContract, "total-liquidity"); + let liquidity = simnet.getDataVar( + reservoirContract, + "available-liquidity" + ); expect(liquidity).toBeUint(2000000000); - // Add liquidity from second provider + // Add liquidity again simnet.callPublicFn( "reservoir", "add-liquidity", [Cl.none(), Cl.uint(1500000000)], - address1 + deployer ); // Check updated total liquidity - liquidity = simnet.getDataVar(reservoirContract, "total-liquidity"); + liquidity = simnet.getDataVar(reservoirContract, "available-liquidity"); expect(liquidity).toBeUint(3500000000); // Remove some liquidity simnet.callPublicFn( "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(500000000)], + "withdraw-liquidity", + [Cl.none(), Cl.uint(500000000), Cl.principal(deployer)], deployer ); // Check updated total liquidity after removal - liquidity = simnet.getDataVar(reservoirContract, "total-liquidity"); + liquidity = simnet.getDataVar(reservoirContract, "available-liquidity"); expect(liquidity).toBeUint(3000000000); }); }); @@ -1144,510 +894,6 @@ describe("reservoir", () => { }); }); - describe("withdraw-queue", () => { - const provider1 = address2; - const provider2 = address3; - const provider3 = address4; - - // Initialize contracts before tests - beforeEach(() => { - // Initialize stackflow contract - simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); - - // Initialize reservoir contract with test token - simnet.callPublicFn( - "reservoir", - "init", - [ - Cl.principal(stackflowContract), - Cl.none(), - Cl.uint(100), // initial borrow rate (1%) - ], - deployer - ); - }); - - it("successfully adds a withdrawal to the queue", () => { - // First, providers need to add liquidity - // Add liquidity with provider1 - const addLiquidity1 = simnet.callPublicFn( - "reservoir", - "add-liquidity", - [ - Cl.none(), - Cl.uint(1000000000), // 1000 tokens - ], - provider1 - ); - expect(addLiquidity1.result).toBeOk(Cl.bool(true)); - - // Fund initial tap - const createTap = simnet.callPublicFn( - "reservoir", - "create-tap", - [ - Cl.principal(stackflowContract), - Cl.none(), - Cl.uint(1000000), - Cl.uint(0), - ], - address1 - ); - expect(createTap.result.type).toBe(ClarityType.ResponseOk); - - const amount = 50000; - const fee = 5000; // 10% of amount - - const mySignature = generateDepositSignature( - address1PK, - null, - address1, - reservoirContract, - 1000000, - 50000, - 1, - reservoirContract - ); - - const reservoirSignature = generateDepositSignature( - deployerPK, - null, - reservoirContract, - address1, - 50000, - 1000000, - 1, - reservoirContract - ); - - const borrow = simnet.callPublicFn( - "reservoir", - "borrow-liquidity", - [ - Cl.principal(stackflowContract), - Cl.uint(amount), - Cl.uint(fee), - Cl.none(), - Cl.uint(1000000), - Cl.uint(50000), - Cl.buffer(mySignature), - Cl.buffer(reservoirSignature), - Cl.uint(1), - ], - address1 - ); - expect(borrow.result.type).toBe(ClarityType.ResponseOk); - - // Request withdrawal of full amount from provider1, but it should be - // queued because the liquidity is not available. - const { result: withdraw1 } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(1000000000)], - provider1 - ); - expect(withdraw1).toBeOk(Cl.none()); - - // Check balances - const stxBalances = simnet.getAssetsMap().get("STX")!; - const reservoirBalance = stxBalances.get(reservoirContract); - // 1000000000 - 50000 (borrowed) + 5000 (fee) - expect(reservoirBalance).toBe(999955000n); - const tapBalance = stxBalances.get(stackflowContract); - // 1000000 (initial) + 50000 (borrowed) - expect(tapBalance).toBe(1050000n); - - // Check that the withdrawal request was added to the queue - const withdrawQueue = simnet.getDataVar( - reservoirContract, - "withdraw-queue" - ); - expect(withdrawQueue).toBeList([ - Cl.tuple({ - provider: Cl.principal(provider1), - amount: Cl.uint(1000000000), - }), - ]); - }); - - it("successfully processes a withdrawal from the queue", () => { - // First, providers need to add liquidity - // Add liquidity with provider1 - const addLiquidity1 = simnet.callPublicFn( - "reservoir", - "add-liquidity", - [ - Cl.none(), - Cl.uint(1000000000), // 1000 tokens - ], - provider1 - ); - expect(addLiquidity1.result).toBeOk(Cl.bool(true)); - - // Fund initial tap - const createTap = simnet.callPublicFn( - "reservoir", - "create-tap", - [ - Cl.principal(stackflowContract), - Cl.none(), - Cl.uint(1000000), - Cl.uint(0), - ], - address1 - ); - expect(createTap.result.type).toBe(ClarityType.ResponseOk); - - const amount = 50000; - const fee = 5000; // 10% of amount - - const mySignature = generateDepositSignature( - address1PK, - null, - address1, - reservoirContract, - 1000000, - 50000, - 1, - reservoirContract - ); - - const reservoirSignature = generateDepositSignature( - deployerPK, - null, - reservoirContract, - address1, - 50000, - 1000000, - 1, - reservoirContract - ); - - const borrow = simnet.callPublicFn( - "reservoir", - "borrow-liquidity", - [ - Cl.principal(stackflowContract), - Cl.uint(amount), - Cl.uint(fee), - Cl.none(), - Cl.uint(1000000), - Cl.uint(50000), - Cl.buffer(mySignature), - Cl.buffer(reservoirSignature), - Cl.uint(1), - ], - address1 - ); - expect(borrow.result.type).toBe(ClarityType.ResponseOk); - - simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); - - // Request withdrawal of full amount from provider1, but it should be - // queued because the liquidity is not available. - const { result: withdraw1 } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(1000000000)], - provider1 - ); - expect(withdraw1).toBeOk(Cl.none()); - - // Return the borrowed liquidity to the reservoir - // Generate signature for returning liquidity - const myReturnSignature = generateWithdrawSignature( - address1PK, - null, - address1, - reservoirContract, - 1000000, - 0, - 2, - reservoirContract - ); - const reservoirReturnSignature = generateWithdrawSignature( - deployerPK, - null, - reservoirContract, - address1, - 0, - 1000000, - 2, - reservoirContract - ); - - const returnLiquidity = simnet.callPublicFn( - reservoirContract, - "return-liquidity-to-reservoir", - [ - Cl.principal(stackflowContract), - Cl.none(), - Cl.principal(address1), - Cl.uint(50000), // Return the borrowed amount - Cl.uint(1000000), - Cl.uint(0), - Cl.buffer(myReturnSignature), - Cl.buffer(reservoirReturnSignature), - Cl.uint(2), - ], - deployer - ); - expect(returnLiquidity.result.type).toBe(ClarityType.ResponseOk); - - // Now try to process the withdrawal queue - const { result: processQueue } = simnet.callPrivateFn( - reservoirContract, - "process-withdrawals", - [Cl.none()], - deployer - ); - expect(processQueue).toBeList([]); - - // Check balances - const stxBalances = simnet.getAssetsMap().get("STX")!; - const reservoirBalance = stxBalances.get(reservoirContract); - // 5000 (fee) - expect(reservoirBalance).toBe(5000n); - const tapBalance = stxBalances.get(stackflowContract); - // 1000000 (initial) - expect(tapBalance).toBe(1000000n); - - // Check that the withdrawal request was removed from the queue - const withdrawQueue = simnet.getDataVar( - reservoirContract, - "withdraw-queue" - ); - expect(withdrawQueue).toBeList([]); - }); - - it("successfully queues multiple withdrawals", () => { - const stxBalancesInitial = simnet.getAssetsMap().get("STX")!; - const provider1BalanceInitial = stxBalancesInitial.get(provider1); - - // First, providers need to add liquidity - // Add liquidity with provider1 - const addLiquidity1 = simnet.callPublicFn( - "reservoir", - "add-liquidity", - [ - Cl.none(), - Cl.uint(1_000_000_000), // 1000 tokens - ], - provider1 - ); - expect(addLiquidity1.result).toBeOk(Cl.bool(true)); - - const addLiquidity2 = simnet.callPublicFn( - "reservoir", - "add-liquidity", - [ - Cl.none(), - Cl.uint(1_000_000_000), // 1000 tokens - ], - provider2 - ); - expect(addLiquidity2.result).toBeOk(Cl.bool(true)); - - const addLiquidity3 = simnet.callPublicFn( - "reservoir", - "add-liquidity", - [ - Cl.none(), - Cl.uint(1_000_000_000), // 1000 tokens - ], - provider3 - ); - expect(addLiquidity3.result).toBeOk(Cl.bool(true)); - - // Reservoir balance should be 3,000 - const stxBalances = simnet.getAssetsMap().get("STX")!; - const reservoirBalance = stxBalances.get(reservoirContract); - expect(reservoirBalance).toBe(3000000000n); - - // Fund initial tap - const createTap = simnet.callPublicFn( - "reservoir", - "create-tap", - [ - Cl.principal(stackflowContract), - Cl.none(), - Cl.uint(1_000_000), - Cl.uint(0), - ], - address1 - ); - expect(createTap.result.type).toBe(ClarityType.ResponseOk); - - const amount = 2_500_000_000; - const fee = 250_000_000; // 10% of amount - - const mySignature = generateDepositSignature( - address1PK, - null, - address1, - reservoirContract, - 1_000_000, - amount, - 1, - reservoirContract - ); - - const reservoirSignature = generateDepositSignature( - deployerPK, - null, - reservoirContract, - address1, - amount, - 1_000_000, - 1, - reservoirContract - ); - - const borrow = simnet.callPublicFn( - "reservoir", - "borrow-liquidity", - [ - Cl.principal(stackflowContract), - Cl.uint(amount), - Cl.uint(fee), - Cl.none(), - Cl.uint(1_000_000), - Cl.uint(amount), - Cl.buffer(mySignature), - Cl.buffer(reservoirSignature), - Cl.uint(1), - ], - address1 - ); - expect(borrow.result.type).toBe(ClarityType.ResponseOk); - - simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); - - const stxBalances0 = simnet.getAssetsMap().get("STX")!; - const reservoirBalance0 = stxBalances0.get(reservoirContract); - const tapBalance0 = stxBalances0.get(stackflowContract); - expect(reservoirBalance0).toBe(750_000_000n); - expect(tapBalance0).toBe(2_501_000_000n); - - // Request withdrawal of full amount from provider1, but it should be - // queued because the liquidity is not available. - const { result: withdraw1 } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(1_000_000_000)], - provider1 - ); - expect(withdraw1).toBeOk(Cl.none()); - - // Request withdrawal of a partial amount from provider2, but it should be - // queued because there is a pending withdrawal in the queue that cannot be - // processed yet. - const { result: withdraw2 } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(500_000_000)], - provider2 - ); - expect(withdraw2).toBeOk(Cl.none()); - - // Request withdrawal of full amount from provider3, but it should be - // queued because there is a pending withdrawal in the queue that cannot be - // processed yet (and there is not enough liquidity). - const { result: withdraw3 } = simnet.callPublicFn( - "reservoir", - "withdraw-liquidity-from-reservoir", - [Cl.none(), Cl.uint(1_000_000_000)], - provider3 - ); - expect(withdraw3).toBeOk(Cl.none()); - - // Return 1,100 of the borrowed liquidity to the reservoir, enough to - // process the first withdrawal in the queue. - const myReturnSignature = generateWithdrawSignature( - address1PK, - null, - address1, - reservoirContract, - 1_000_000, - 1_400_000_000, - 2, - reservoirContract - ); - const reservoirReturnSignature = generateWithdrawSignature( - deployerPK, - null, - reservoirContract, - address1, - 1_400_000_000, - 1_000_000, - 2, - reservoirContract - ); - - const returnLiquidity = simnet.callPublicFn( - reservoirContract, - "return-liquidity-to-reservoir", - [ - Cl.principal(stackflowContract), - Cl.none(), - Cl.principal(address1), - Cl.uint(1_100_000_000), - Cl.uint(1_000_000), - Cl.uint(1_400_000_000), - Cl.buffer(myReturnSignature), - Cl.buffer(reservoirReturnSignature), - Cl.uint(2), - ], - deployer - ); - expect(returnLiquidity.result.type).toBe(ClarityType.ResponseOk); - - // Check that this completed the first withdrawal in the queue - const stxBalances1 = simnet.getAssetsMap().get("STX")!; - const reservoirBalance1 = stxBalances1.get(reservoirContract); - // 750 (previously) + 1,100 (returned) - 1,000 (withdrawn) - expect(reservoirBalance1).toBe(850_000_000n); - const tapBalance1 = stxBalances1.get(stackflowContract); - // 2,501,000,000 (initial) - 1,100,000,000 (returned) - expect(tapBalance1).toBe(1_401_000_000n); - const provider1Balance = stxBalances1.get(provider1); - expect(provider1Balance).toBe(provider1BalanceInitial); - - // Check that the withdrawal request was removed from the queue - const withdrawQueue1 = simnet.getDataVar( - reservoirContract, - "withdraw-queue" - ); - expect(withdrawQueue1.type).toBe(ClarityType.List); - expect((withdrawQueue1 as ListCV).value.length).toBe(2); - - // Now try to process the withdrawal queue - const { result: processQueue } = simnet.callPrivateFn( - reservoirContract, - "process-withdrawals", - [Cl.none()], - deployer - ); - expect(processQueue.type).toBe(ClarityType.List); - expect((processQueue as ListCV).value.length).toBe(2); - - // Check balances: should be unchanged from previous check - const stxBalances2 = simnet.getAssetsMap().get("STX")!; - const reservoirBalance2 = stxBalances2.get(reservoirContract); - expect(reservoirBalance2).toBe(850_000_000n); - const tapBalance2 = stxBalances2.get(stackflowContract); - expect(tapBalance2).toBe(1_401_000_000n); - - // Check that the withdrawal request was removed from the queue - const withdrawQueue = simnet.getDataVar( - reservoirContract, - "withdraw-queue" - ); - expect(withdrawQueue.type).toBe(ClarityType.List); - expect((withdrawQueue as ListCV).value.length).toBe(2); - }); - }); - describe("force-closures", () => { beforeEach(() => { // Add liquidity to reservoir diff --git a/tests/utils.ts b/tests/utils.ts index 038e960..754e1f3 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -79,8 +79,7 @@ export enum ReservoirError { NotInitialized = 206, UnapprovedToken = 207, IncorrectStackflow = 208, - AmountTooLow = 209, - LiquidityPoolFull = 210, + AmountNotAvailable = 209, } const structuredDataPrefix = Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); From dcf07b9333cb8db489105e26d3e3995724b8025e Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 13 Dec 2025 17:39:36 -0500 Subject: [PATCH 21/78] test: check multiple liquidity borrows --- tests/reservoir.test.ts | 151 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 12 deletions(-) diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index e97b6c5..a24e30c 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -1,17 +1,8 @@ import { describe, expect, it, beforeEach } from "vitest"; -import { - Cl, - ClarityType, - ResponseOkCV, - UIntCV, - ListCV, -} from "@stacks/transactions"; +import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; import { deployer, address1, - address2, - address3, - address4, address1PK, address2PK, reservoirContract, @@ -22,13 +13,11 @@ import { deployerPK, MAX_HEIGHT, CONFIRMATION_DEPTH, - accounts, generateWithdrawSignature, BORROW_TERM_BLOCKS, PipeAction, generateTransferSignature, } from "./utils"; -import { a } from "vitest/dist/chunks/suite.d.FvehnV49.js"; describe("reservoir", () => { beforeEach(() => { @@ -467,6 +456,144 @@ describe("reservoir", () => { ); }); + it("can borrow additional liquidity before previous term ends", () => { + // Set rate to 10% and fund the reservoir + simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(5000000000)], + deployer + ); + + // Fund initial tap + const tap = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], + address1 + ); + expect(tap.result.type).toBe(ClarityType.ResponseOk); + + const amount1 = 50000; + const fee1 = 5000; + const nonce1 = 1; + + const mySignature1 = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + amount1, + nonce1, + reservoirContract + ); + + const reservoirSignature1 = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount1, + 1000000, + nonce1, + reservoirContract + ); + + const borrow1 = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount1), + Cl.uint(fee1), + Cl.none(), + Cl.uint(1000000), + Cl.uint(amount1), + Cl.buffer(mySignature1), + Cl.buffer(reservoirSignature1), + Cl.uint(nonce1), + ], + address1 + ); + expect(borrow1.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + + // Wait for the first borrow deposit to confirm, but not for the term to expire + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + const amount2 = 75000; + const fee2 = 7500; + const nonce2 = 2; + const userBalance = 1000000; + const reservoirBalance = amount1 + amount2; + const expectedUntil = simnet.burnBlockHeight + BORROW_TERM_BLOCKS; + + const mySignature2 = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance, + reservoirBalance, + nonce2, + reservoirContract + ); + + const reservoirSignature2 = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + reservoirBalance, + userBalance, + nonce2, + reservoirContract + ); + + const borrow2 = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount2), + Cl.uint(fee2), + Cl.none(), + Cl.uint(userBalance), + Cl.uint(reservoirBalance), + Cl.buffer(mySignature2), + Cl.buffer(reservoirSignature2), + Cl.uint(nonce2), + ], + address1 + ); + expect(borrow2.result).toBeOk(Cl.uint(expectedUntil)); + + const borrowEntry = simnet.getMapEntry( + reservoirContract, + "borrowed-liquidity", + Cl.principal(address1) + ); + expect(borrowEntry).toBeSome( + Cl.tuple({ + amount: Cl.uint(amount2), + until: Cl.uint(expectedUntil), + }) + ); + }); + it("cannot borrow with insufficient reservoir liquidity", () => { // Set rate to 10% simnet.callPublicFn( From 9493e25b83deb04f46364654cf31567547b4ba1f Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 13 Dec 2025 19:52:35 -0500 Subject: [PATCH 22/78] feat: remote `available-liquidity` Replace with a function `get-available-liquidity` which just checks the actual balance of the contract. This simplifies things and removes an opportunity for tokens to get stuck in the contract. --- contracts/reservoir.clar | 182 ++++++++++++++++++++------------------- tests/reservoir.test.ts | 85 ++++++++++++------ 2 files changed, 150 insertions(+), 117 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index c90d72d..5f7d419 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -58,9 +58,6 @@ ;;; The StackFlow contract that this Reservoir is registered with. (define-data-var stackflow-contract (optional principal) none) -;;; Available liquidity in the Reservoir. -(define-data-var available-liquidity uint u0) - ;;; Map tracking the borrowed liquidity for each tap holder. (define-map borrowed-liquidity principal @@ -125,17 +122,7 @@ ) (begin (try! (check-valid-token token)) - (unwrap! - (match token - t (contract-call? t transfer amount tx-sender current-contract none) - (stx-transfer? amount tx-sender current-contract) - ) - ERR_FUNDING_FAILED - ) - - ;; Update the total liquidity in the reservoir. - (var-set available-liquidity (+ (var-get available-liquidity) amount)) - + (unwrap! (transfer-to-contract token amount) ERR_FUNDING_FAILED) (ok true) ) ) @@ -157,13 +144,12 @@ (begin (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) (try! (check-valid-token token)) - (asserts! (<= amount (var-get available-liquidity)) ERR_AMOUNT_NOT_AVAILABLE) + (asserts! (<= amount (unwrap-panic (get-available-liquidity token))) + ERR_AMOUNT_NOT_AVAILABLE + ) ;; Perform the withdrawal. - (unwrap! (withdraw-liquidity-to token amount recipient) ERR_TRANSFER_FAILED) - - ;; Update the total liquidity in the reservoir. - (var-set available-liquidity (- (var-get available-liquidity) amount)) + (unwrap! (transfer-from-contract token amount recipient) ERR_TRANSFER_FAILED) (ok amount) ) @@ -245,6 +231,65 @@ ) ) +;;; Return liquidity to the reservoir via a withdrawal as the reservoir. The +;;; reservoir operator will request signatures from the tap holder when the +;;; reservoir's balance has reached a certain threshold. If the user fails to +;;; provide the needed signatures for this withdrawal, then the reservoir will +;;; refuse further transfers to/from the tap holder and eventually force-close +;;; the tap. +;;; Returns: +;;; - `(ok true)` on success +;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one +;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token +(define-public (return-liquidity-to-reservoir + (stackflow ) + (token (optional )) + (user principal) + (amount uint) + (user-balance uint) + (reservoir-balance uint) + (user-signature (buff 65)) + (reservoir-signature (buff 65)) + (nonce uint) + ) + (let ( + (borrow (default-to { + amount: u0, + until: u0, + } + (map-get? borrowed-liquidity user) + )) + (borrowed-amount (if (> burn-block-height (get until borrow)) + u0 + (get amount borrow) + )) + ) + (try! (check-valid stackflow token)) + ;; The reservoir cannot attempt to return liquidity that is still borrowed. + (asserts! (>= reservoir-balance borrowed-amount) ERR_UNAUTHORIZED) + + (print { + topic: "return-liquidity-to-reservoir", + amount: amount, + }) + (try! (match token + t (as-contract? () + (try! (contract-call? stackflow withdraw amount token user reservoir-balance + user-balance reservoir-signature user-signature nonce + )) + ) + (as-contract? () + (try! (contract-call? stackflow withdraw amount token user reservoir-balance + user-balance reservoir-signature user-signature nonce + )) + ) + )) + + (ok true) + ) +) + ;; ----- Functions called by Tap holders ----- ;;; Create a new tap for FT `token` (`none` indicates STX) and deposit @@ -333,13 +378,7 @@ ) (try! (check-valid stackflow token)) (asserts! (>= fee expected-fee) ERR_INVALID_FEE) - (unwrap! - (match token - t (contract-call? t transfer fee tx-sender RESERVOIR none) - (stx-transfer? fee tx-sender RESERVOIR) - ) - ERR_BORROW_FEE_PAYMENT_FAILED - ) + (unwrap! (transfer-to-contract token fee) ERR_BORROW_FEE_PAYMENT_FAILED) (try! (match token t (as-contract? ((with-ft (contract-of t) "*" amount)) (try! (contract-call? stackflow deposit amount token borrower reservoir-balance @@ -359,72 +398,10 @@ until: until, }) - ;; Update the total liquidity in the reservoir. - (var-set available-liquidity (- (var-get available-liquidity) amount)) - (ok until) ) ) -;;; Return liquidity to the reservoir via a withdrawal as the reservoir. The -;;; reservoir operator will request signatures from the tap holder when the -;;; reservoir's balance has reached a certain threshold. If the user fails to -;;; provide the needed signatures for this withdrawal, then the reservoir will -;;; refuse further transfers to/from the tap holder and eventually force-close -;;; the tap. -;;; Returns: -;;; - `(ok true)` on success -;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized -;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one -;;; - `ERR_UNAPPROVED_TOKEN` if the token is not the correct token -(define-public (return-liquidity-to-reservoir - (stackflow ) - (token (optional )) - (user principal) - (amount uint) - (user-balance uint) - (reservoir-balance uint) - (user-signature (buff 65)) - (reservoir-signature (buff 65)) - (nonce uint) - ) - (let ( - (borrow (default-to { - amount: u0, - until: u0, - } - (map-get? borrowed-liquidity user) - )) - (borrowed-amount (if (> burn-block-height (get until borrow)) - u0 - (get amount borrow) - )) - ) - (try! (check-valid stackflow token)) - ;; The reservoir cannot attempt to return liquidity that is still borrowed. - (asserts! (>= reservoir-balance borrowed-amount) ERR_UNAUTHORIZED) - - (print { - topic: "return-liquidity-to-reservoir", - amount: amount, - }) - (try! (match token - t (as-contract? () - (try! (contract-call? stackflow withdraw amount token user reservoir-balance - user-balance reservoir-signature user-signature nonce - )) - ) - (as-contract? () - (try! (contract-call? stackflow withdraw amount token user reservoir-balance - user-balance reservoir-signature user-signature nonce - )) - ) - )) - - (ok true) - ) -) - ;; ----- Read-only functions ----- ;;; Calculate the fee for borrowing a given amount. @@ -433,9 +410,25 @@ (/ (* amount (var-get borrow-rate)) u10000) ) +;;; Get the available liquidity in the reservoir for `token`. +;;; NB: This cannot be a read-only function because of the contract-call? in +;;; the FT case. Instead, it must be a public function that returns a response +;;; but it is written such that it only returns `ok` values. Users should use +;;; `unwrap-panic` on its result. +(define-public (get-available-liquidity (token (optional ))) + (ok (match token + t (match (contract-call? t get-balance current-contract) + balance balance + e u0 + ) + (stx-get-balance current-contract) + )) +) + ;; ---- Private helper functions ----- -(define-private (withdraw-liquidity-to +;;; Transfer `amount` of `token` from this contract to `recipient`. +(define-private (transfer-from-contract (token (optional )) (amount uint) (recipient principal) @@ -450,6 +443,17 @@ ) ) +;;; Transfer 'amount' of 'token' from `tx-sender` to this contract. +(define-private (transfer-to-contract + (token (optional )) + (amount uint) + ) + (match token + t (contract-call? t transfer amount tx-sender current-contract none) + (stx-transfer? amount tx-sender current-contract) + ) +) + ;;; Given an optional trait, return an optional principal for the trait. (define-private (contract-of-optional (trait (optional ))) (match trait diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index a24e30c..21a71e5 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -152,12 +152,14 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n); - // Verify available-liquidity - const totalLiquidity = simnet.getDataVar( - reservoirContract, - "available-liquidity" + // Verify get-available-liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer ); - expect(totalLiquidity).toBeUint(1000000000n); + expect(available).toBeOk(Cl.uint(1000000000n)); }); it("operator can remove their own unused STX liquidity", () => { @@ -183,12 +185,14 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(9999200000n); - // Verify available-liquidity - const totalLiquidity = simnet.getDataVar( - reservoirContract, - "available-liquidity" + // Verify get-available-liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer ); - expect(totalLiquidity).toBeUint(9999200000n); + expect(available).toBeOk(Cl.uint(9999200000n)); }); it("operator can remove their own unused STX liquidity to another address", () => { @@ -214,12 +218,14 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(9999200000n); - // Verify available-liquidity - const totalLiquidity = simnet.getDataVar( - reservoirContract, - "available-liquidity" + // Verify get-available-liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer ); - expect(totalLiquidity).toBeUint(9999200000n); + expect(available).toBeOk(Cl.uint(9999200000n)); }); it("operator cannot remove more than the available liquidity", () => { @@ -245,12 +251,14 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n); - // Verify available-liquidity remains unchanged - const totalLiquidity = simnet.getDataVar( - reservoirContract, - "available-liquidity" + // Verify get-available-liquidity remains unchanged + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer ); - expect(totalLiquidity).toBeUint(1000000000n); + expect(available).toBeOk(Cl.uint(1000000000n)); }); it("non-operator cannot remove liquidity", () => { @@ -282,11 +290,13 @@ describe("reservoir", () => { ); // Check total liquidity - let liquidity = simnet.getDataVar( - reservoirContract, - "available-liquidity" + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer ); - expect(liquidity).toBeUint(2000000000); + expect(available).toBeOk(Cl.uint(2000000000)); // Add liquidity again simnet.callPublicFn( @@ -297,8 +307,13 @@ describe("reservoir", () => { ); // Check updated total liquidity - liquidity = simnet.getDataVar(reservoirContract, "available-liquidity"); - expect(liquidity).toBeUint(3500000000); + const { result: availableAfterAdd } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(availableAfterAdd).toBeOk(Cl.uint(3500000000)); // Remove some liquidity simnet.callPublicFn( @@ -309,8 +324,13 @@ describe("reservoir", () => { ); // Check updated total liquidity after removal - liquidity = simnet.getDataVar(reservoirContract, "available-liquidity"); - expect(liquidity).toBeUint(3000000000); + const { result: availableAfterWithdraw } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(availableAfterWithdraw).toBeOk(Cl.uint(3000000000)); }); }); @@ -917,6 +937,15 @@ describe("reservoir", () => { // Verify the reservoir balance after returning liquidity const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n + 5000n); + + // get-available-liquidity returns actual tokens held by the reservoir + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(available).toBeOk(Cl.uint(1000005000n)); }); it("cannot return liquidity before borrow term ends", () => { From c2a50b5d7b1a26e20945146d577a16b834f2e943 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 11 Feb 2026 04:28:25 -0500 Subject: [PATCH 23/78] add deploy script --- .gitignore | 2 ++ package-lock.json | 16 +++++++++- package.json | 5 ++- scripts/deploy.js | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 scripts/deploy.js diff --git a/.gitignore b/.gitignore index f2e80a1..6ca17dc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ coverage costs-reports.json node_modules .debug_history +**/.env + diff --git a/package-lock.json b/package-lock.json index 7603d6c..667ff6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "ISC", "dependencies": { "@stacks/clarinet-sdk": "^3.10.0", - "@stacks/transactions": "^7.0.6", + "@stacks/network": "^7.2.0", + "@stacks/transactions": "^7.2.0", "chokidar-cli": "^3.0.0", + "dotenv": "^16.4.7", "typescript": "^5.3.3", "vite": "^6.2.6", "vitest": "^3.1.1", @@ -1291,6 +1293,18 @@ "node": ">=6" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", diff --git a/package.json b/package.json index 01f65eb..13cb76b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "private": true, "scripts": { + "deploy:testnet": "node scripts/deploy.js", "test": "vitest run", "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"" @@ -12,9 +13,11 @@ "author": "", "license": "ISC", "dependencies": { + "@stacks/network": "^7.2.0", "@stacks/clarinet-sdk": "^3.10.0", - "@stacks/transactions": "^7.0.6", + "@stacks/transactions": "^7.2.0", "chokidar-cli": "^3.0.0", + "dotenv": "^16.4.7", "typescript": "^5.3.3", "vite": "^6.2.6", "vitest": "^3.1.1", diff --git a/scripts/deploy.js b/scripts/deploy.js new file mode 100644 index 0000000..c6908cd --- /dev/null +++ b/scripts/deploy.js @@ -0,0 +1,80 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import "dotenv/config"; +import { + AnchorMode, + ClarityVersion, + broadcastTransaction, + getAddressFromPrivateKey, + makeContractDeploy, + fetchNonce, +} from "@stacks/transactions"; +import { STACKS_TESTNET } from "@stacks/network"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const STACKS_API_URL = + process.env.STACKS_API_URL ?? "https://api.testnet.hiro.so"; +const DEPLOYER_PRIVATE_KEY = process.env.DEPLOYER_PRIVATE_KEY; + +if (!DEPLOYER_PRIVATE_KEY) { + console.error("DEPLOYER_PRIVATE_KEY is required in the environment"); + process.exit(1); +} + +const VERSION = process.env.VERSION; +if (!VERSION) { + console.error("VERSION is required in the environment"); + process.exit(1); +} + +const CONTRACTS = [ + { + name: `stackflow-token-${VERSION}`, + file: "../contracts/stackflow-token.clar", + }, + { name: `stackflow-${VERSION}`, file: "../contracts/stackflow.clar" }, + { name: `reservoir-${VERSION}`, file: "../contracts/reservoir.clar" }, +]; + +const network = STACKS_TESTNET; + +async function deployContract(contractName, filePath, nonce) { + const senderAddress = getAddressFromPrivateKey(DEPLOYER_PRIVATE_KEY, network); + console.log( + `Deploying contracts from address, ${senderAddress}, starting with nonce ${nonce}` + ); + + const codeBody = fs.readFileSync(path.resolve(__dirname, filePath), "utf8"); + + const txOptions = { + contractName, + codeBody, + senderKey: DEPLOYER_PRIVATE_KEY, + nonce, + network, + clarityVersion: ClarityVersion.Clarity4, + anchorMode: AnchorMode.Any, + }; + + const transaction = await makeContractDeploy(txOptions); + const resp = await broadcastTransaction({ transaction, network }); + console.log(`${contractName} tx broadcast:`, resp); + return resp; +} + +async function main() { + const senderAddress = getAddressFromPrivateKey(DEPLOYER_PRIVATE_KEY, network); + let nonce = await fetchNonce(senderAddress, network); + for (const contract of CONTRACTS) { + await deployContract(contract.name, contract.file, nonce); + nonce += 1; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 129f398ce740271f7d3c3bb22b26fa4f198151e2 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 11 Feb 2026 04:31:36 -0500 Subject: [PATCH 24/78] fix: use trait in `add-funds` --- contracts/reservoir.clar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 5f7d419..06eea2c 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -345,7 +345,7 @@ ) (begin (try! (check-valid stackflow token)) - (contract-call? .stackflow deposit amount token RESERVOIR my-balance + (contract-call? stackflow deposit amount token RESERVOIR my-balance their-balance my-signature their-signature nonce ) ) From 81b1bd5efee6e8209cb5c7d4511dfef7580bbcaf Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 11 Feb 2026 04:54:20 -0500 Subject: [PATCH 25/78] fix: missing authorizations in reservoir --- contracts/reservoir.clar | 3 +++ deployments/default.simnet-plan.yaml | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 06eea2c..50aa7cc 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -176,6 +176,7 @@ (get amount borrow) )) ) + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) (try! (check-valid stackflow token)) ;; The reservoir cannot attempt to force-cancel a tap that has borrowed liquidity. @@ -216,6 +217,7 @@ (get amount borrow) )) ) + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) (try! (check-valid stackflow token)) ;; The reservoir cannot attempt to force-close a tap that has borrowed liquidity. @@ -265,6 +267,7 @@ (get amount borrow) )) ) + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) (try! (check-valid stackflow token)) ;; The reservoir cannot attempt to return liquidity that is still borrowed. (asserts! (>= reservoir-balance borrowed-amount) ERR_UNAUTHORIZED) diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index f72310c..d990d2b 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -85,14 +85,14 @@ plan: path: contracts/stackflow-token.clar clarity-version: 4 - emulated-contract-publish: - contract-name: stackflow + contract-name: reservoir emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow.clar + path: contracts/reservoir.clar clarity-version: 4 - emulated-contract-publish: - contract-name: reservoir + contract-name: stackflow emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/reservoir.clar + path: contracts/stackflow.clar clarity-version: 4 - emulated-contract-publish: contract-name: test-token From 6b4ea36bee0351c9f59cad587c88fb4b825395c1 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 11 Feb 2026 05:54:53 -0500 Subject: [PATCH 26/78] fix: various items from audit --- README.md | 4 +- contracts/reservoir.clar | 1 + contracts/stackflow.clar | 8 +- scripts/deploy.js | 90 ++++++++++++-- tests/reservoir.test.ts | 257 +++++++++++++++++++++++++++++++++++++++ tests/stackflow.test.ts | 221 +++++++++++++++++++++++++++++++++ tests/utils.ts | 2 +- 7 files changed, 570 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index aacfcb1..2f60ef4 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ messages include a nonce to track their ordering. indicating the agreed upon balances of each participant. These signed messages can be exchanged off-chain and only need to be submitted on-chain when closing the Pipe, depositing additional funds to, or withdrawing funds from the Pipe. -The messages include a nonce to track their ordering. +The messages include a nonce to track their ordering. The SIP-018 domain is +bound to the specific StackFlow contract principal, so signatures are not +reusable across different StackFlow contract instances. A Pipe can be closed cooperatively at any time, with both parties agreeing on the final balances and signing off on the closure. If either party becomes diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 50aa7cc..f9543e3 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -121,6 +121,7 @@ (amount uint) ) (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) (try! (check-valid-token token)) (unwrap! (transfer-to-contract token amount) ERR_FUNDING_FAILED) (ok true) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 27090e9..e8b0818 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -39,7 +39,7 @@ ;; Constants for SIP-018 structured data (define-constant structured-data-prefix 0x534950303138) (define-constant message-domain-hash (sha256 (unwrap-panic (to-consensus-buff? { - name: "StackFlow", + name: (unwrap-panic (to-ascii? current-contract)), version: "0.6.0", chain-id: chain-id, })))) @@ -199,7 +199,7 @@ (closer (get closer pipe)) ) ;; If there was an existing pipe, the new nonce must be equal or greater - (asserts! (>= (get nonce pipe) nonce) ERR_NONCE_TOO_LOW) + (asserts! (>= nonce (get nonce pipe)) ERR_NONCE_TOO_LOW) ;; A forced closure must not be in progress (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) @@ -562,7 +562,7 @@ (asserts! (is-some closer) ERR_NO_CLOSE_IN_PROGRESS) ;; The waiting period must have passed - (asserts! (> burn-block-height expires-at) ERR_NOT_EXPIRED) + (asserts! (>= burn-block-height expires-at) ERR_NOT_EXPIRED) ;; Reset the pipe in the map. (reset-pipe pipe-key (get nonce pipe)) @@ -767,7 +767,7 @@ ;; Withdrawal amount cannot be greater than the total pipe balance (asserts! - (> (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) amount) + (>= (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) amount) ERR_INVALID_WITHDRAWAL ) diff --git a/scripts/deploy.js b/scripts/deploy.js index c6908cd..da3d169 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -30,24 +30,94 @@ if (!VERSION) { process.exit(1); } +const VERSION_TAG = VERSION.replaceAll(".", "-"); +const VERSION_DISPLAY = VERSION.replaceAll("-", "."); +const STACKFLOW_TOKEN_CONTRACT_NAME = `stackflow-token-${VERSION_TAG}`; +const TESTNET_SIP_010_TRAIT_ADDRESS = + "ST1NXBK3K5YYMD6FD41MVNP3JS1GABZ8TRVX023PT"; + const CONTRACTS = [ { - name: `stackflow-token-${VERSION}`, + name: STACKFLOW_TOKEN_CONTRACT_NAME, + kind: "stackflow-token", file: "../contracts/stackflow-token.clar", }, - { name: `stackflow-${VERSION}`, file: "../contracts/stackflow.clar" }, - { name: `reservoir-${VERSION}`, file: "../contracts/reservoir.clar" }, + { + name: `stackflow-${VERSION_TAG}`, + kind: "stackflow", + file: "../contracts/stackflow.clar", + }, + { + name: `reservoir-${VERSION_TAG}`, + kind: "reservoir", + file: "../contracts/reservoir.clar", + }, ]; const network = STACKS_TESTNET; -async function deployContract(contractName, filePath, nonce) { - const senderAddress = getAddressFromPrivateKey(DEPLOYER_PRIVATE_KEY, network); +function replaceRequired(source, pattern, replacement, description) { + const updated = source.replace(pattern, replacement); + if (updated === source) { + throw new Error(`Unable to update ${description}`); + } + return updated; +} + +function buildContractCode(filePath, kind, senderAddress) { + let codeBody = fs.readFileSync(path.resolve(__dirname, filePath), "utf8"); + + codeBody = replaceRequired( + codeBody, + /(;;\s*version:\s*)([^\n]+)/, + `$1${VERSION_DISPLAY}`, + `${kind} version metadata` + ); + + codeBody = replaceRequired( + codeBody, + /^(\s*\(use-trait\s+sip-010\s+')[A-Z0-9]+(\.sip-010-trait-ft-standard\.sip-010-trait\))/m, + `$1${TESTNET_SIP_010_TRAIT_ADDRESS}$2`, + `${kind} sip-010 trait reference` + ); + + if (kind === "stackflow") { + codeBody = replaceRequired( + codeBody, + /^(\s*version:\s*")[^"]+(")/m, + `$1${VERSION_DISPLAY}$2`, + "stackflow SIP-018 domain version" + ); + codeBody = replaceRequired( + codeBody, + /^(\s*\(impl-trait\s+)(?:\.stackflow-token(?:-[A-Za-z0-9.-]+)?|'[A-Z0-9]+\.(?:stackflow-token(?:-[A-Za-z0-9.-]+)?))(\.stackflow-token\))/m, + `$1'${senderAddress}.${STACKFLOW_TOKEN_CONTRACT_NAME}$2`, + "stackflow token trait reference" + ); + } + + if (kind === "reservoir") { + codeBody = codeBody.replace( + /^\s*;;\s*\(use-trait\s+stackflow-token\s+'[A-Z0-9]+\.(?:stackflow-token(?:-[A-Za-z0-9.-]+)?)\.stackflow-token\)\s*$/gm, + "" + ); + codeBody = replaceRequired( + codeBody, + /^(\s*\(use-trait\s+stackflow-token\s+)(?:\.stackflow-token(?:-[A-Za-z0-9.-]+)?|'[A-Z0-9]+\.(?:stackflow-token(?:-[A-Za-z0-9.-]+)?))(\.stackflow-token\))/m, + `$1'${senderAddress}.${STACKFLOW_TOKEN_CONTRACT_NAME}$2`, + "reservoir token trait reference" + ); + } + + return codeBody; +} + +async function deployContract(contractName, filePath, kind, nonce, senderAddress) { console.log( `Deploying contracts from address, ${senderAddress}, starting with nonce ${nonce}` ); - const codeBody = fs.readFileSync(path.resolve(__dirname, filePath), "utf8"); + const codeBody = buildContractCode(filePath, kind, senderAddress); const txOptions = { contractName, @@ -69,7 +139,13 @@ async function main() { const senderAddress = getAddressFromPrivateKey(DEPLOYER_PRIVATE_KEY, network); let nonce = await fetchNonce(senderAddress, network); for (const contract of CONTRACTS) { - await deployContract(contract.name, contract.file, nonce); + await deployContract( + contract.name, + contract.file, + contract.kind, + nonce, + senderAddress + ); nonce += 1; } } diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 21a71e5..7c8ed53 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -3,6 +3,7 @@ import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; import { deployer, address1, + address2, address1PK, address2PK, reservoirContract, @@ -162,6 +163,20 @@ describe("reservoir", () => { expect(available).toBeOk(Cl.uint(1000000000n)); }); + it("non-operator cannot add STX liquidity", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000000)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance ?? 0n).toBe(0n); + }); + it("operator can remove their own unused STX liquidity", () => { // First add liquidity simnet.callPublicFn( @@ -1048,6 +1063,95 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n - 50000n + 5000n); }); + + it("non-operator cannot return liquidity after borrow term ends", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 50000, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + 50000, + 1000000, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); + + const myReturnSignature = generateWithdrawSignature( + address1PK, + null, + address1, + reservoirContract, + 1000000, + 0, + 2, + reservoirContract + ); + const reservoirReturnSignature = generateWithdrawSignature( + deployerPK, + null, + reservoirContract, + address1, + 0, + 1000000, + 2, + reservoirContract + ); + + const returnLiquidity = simnet.callPublicFn( + "reservoir", + "return-liquidity-to-reservoir", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.principal(address1), + Cl.uint(50000), + Cl.uint(1000000), + Cl.uint(0), + Cl.buffer(myReturnSignature), + Cl.buffer(reservoirReturnSignature), + Cl.uint(2), + ], + address2 + ); + expect(returnLiquidity.result).toBeErr( + Cl.uint(ReservoirError.Unauthorized) + ); + }); }); describe("force-closures", () => { @@ -1406,5 +1510,158 @@ describe("reservoir", () => { const reservoirBalance = stxBalances.get(reservoirContract); expect(reservoirBalance).toBe(1000000000n - 50000n + 5000n); }); + + it("non-operator cannot force-cancel after borrow term ends", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + const userBalance = 1000000; + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance, + amount, + 1, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount, + userBalance, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(1000000), + Cl.uint(50000), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); + + const forceCancel = simnet.callPublicFn( + "reservoir", + "force-cancel-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.principal(address1), + ], + address2 + ); + expect(forceCancel.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + + it("non-operator cannot force-close after borrow term ends", () => { + const amount = 50000; + const fee = 5000; // 10% of amount + const userBalance = 1000000; + + const myDepositSignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance, + amount, + 1, + reservoirContract + ); + + const reservoirDepositSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + amount, + userBalance, + 1, + reservoirContract + ); + + const borrow = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(userBalance), + Cl.uint(amount), + Cl.buffer(myDepositSignature), + Cl.buffer(reservoirDepositSignature), + Cl.uint(1), + ], + address1 + ); + expect(borrow.result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); + + const mySignature = generateTransferSignature( + address1PK, + null, + address1, + reservoirContract, + userBalance + 100, + amount - 100, + 5, + address1 + ); + + const reservoirSignature = generateTransferSignature( + deployerPK, + null, + reservoirContract, + address1, + amount - 100, + userBalance + 100, + 5, + address1 + ); + + const forceClose = simnet.callPublicFn( + "reservoir", + "force-close-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.principal(address1), + Cl.uint(userBalance + 100), + Cl.uint(amount - 100), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(5), + Cl.uint(PipeAction.Transfer), + Cl.principal(address1), + Cl.none(), + Cl.none(), + ], + address2 + ); + expect(forceClose.result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); }); }); diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 717850d..c64c94f 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -528,6 +528,95 @@ describe("fund-pipe", () => { expect(contractBalance).toBe(3000000n); }); + it("can fund a previously closed pipe with a higher nonce", () => { + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + const closeSignature1 = generateClosePipeSignature( + address1PK, + null, + address1, + address2, + 1000000, + 2000000, + 1, + address1 + ); + const closeSignature2 = generateClosePipeSignature( + address2PK, + null, + address2, + address1, + 2000000, + 1000000, + 1, + address1 + ); + + const { result: closeResult } = simnet.callPublicFn( + "stackflow", + "close-pipe", + [ + Cl.none(), + Cl.principal(address2), + Cl.uint(1000000), + Cl.uint(2000000), + Cl.buffer(closeSignature1), + Cl.buffer(closeSignature2), + Cl.uint(1), + ], + address1 + ); + expect(closeResult).toBeOk(Cl.bool(false)); + + const { result: fundResult } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(500000), Cl.principal(address2), Cl.uint(2)], + address1 + ); + expect(fundResult).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(address2), + }) + ); + + const pipeKey = (fundResult as ResponseOkCV).value; + const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); + expect(pipe).toBeSome( + Cl.tuple({ + "balance-1": Cl.uint(0), + "balance-2": Cl.uint(0), + "pending-1": Cl.some( + Cl.tuple({ + amount: Cl.uint(500000), + "burn-height": Cl.uint(simnet.burnBlockHeight + CONFIRMATION_DEPTH), + }) + ), + "pending-2": Cl.none(), + "expires-at": Cl.uint(MAX_HEIGHT), + nonce: Cl.uint(1), + closer: Cl.none(), + }) + ); + }); + it("cannot fund pipe with unapproved token", () => { // Initialize the contract for STX simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); @@ -3845,6 +3934,44 @@ describe("finalize", () => { const contractBalance = stxBalances.get(stackflowContract); expect(contractBalance).toBe(3000000n); }); + + it("can finalize exactly at the expiry height", () => { + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + const cancelHeight = simnet.burnBlockHeight; + const { result: cancelResult } = simnet.callPublicFn( + "stackflow", + "force-cancel", + [Cl.none(), Cl.principal(address2)], + address1 + ); + expect(cancelResult).toBeOk(Cl.uint(cancelHeight + WAITING_PERIOD)); + + simnet.mineEmptyBurnBlocks(WAITING_PERIOD); + + const { result: finalizeResult } = simnet.callPublicFn( + "stackflow", + "finalize", + [Cl.none(), Cl.principal(address2)], + address1 + ); + expect(finalizeResult).toBeOk(Cl.bool(false)); + }); }); describe("deposit", () => { @@ -4740,6 +4867,100 @@ describe("withdraw", () => { ); }); + it("can withdraw the full pipe balance", () => { + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + const { result: fundResult } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + expect(fundResult).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(address2), + }) + ); + const pipeKey = (fundResult as ResponseOkCV).value; + + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + + const signature1 = generateWithdrawSignature( + address1PK, + null, + address1, + address2, + 0, + 0, + 1, + address1 + ); + const signature2 = generateWithdrawSignature( + address2PK, + null, + address2, + address1, + 0, + 0, + 1, + address1 + ); + + const { result } = simnet.callPublicFn( + "stackflow", + "withdraw", + [ + Cl.uint(3000000), + Cl.none(), + Cl.principal(address2), + Cl.uint(0), + Cl.uint(0), + Cl.buffer(signature1), + Cl.buffer(signature2), + Cl.uint(1), + ], + address1 + ); + expect(result).toBeOk( + Cl.tuple({ + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(address2), + token: Cl.none(), + }) + ); + + const stxBalances = simnet.getAssetsMap().get("STX")!; + const balance1 = stxBalances.get(address1); + expect(balance1).toBe(100000002000000n); + + const balance2 = stxBalances.get(address2); + expect(balance2).toBe(99999998000000n); + + const contractBalance = stxBalances.get(stackflowContract); + expect(contractBalance).toBe(0n); + + const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); + expect(pipe).toBeSome( + Cl.tuple({ + "balance-1": Cl.uint(0), + "balance-2": Cl.uint(0), + "expires-at": Cl.uint(MAX_HEIGHT), + nonce: Cl.uint(1), + closer: Cl.none(), + "pending-1": Cl.none(), + "pending-2": Cl.none(), + }) + ); + }); + it("cannot withdraw with a bad sender signature", () => { // Initialize the contract for STX simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); diff --git a/tests/utils.ts b/tests/utils.ts index 754e1f3..3c993d7 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -99,7 +99,7 @@ function structuredDataHash(structuredData: ClarityValue): Buffer { const domainHash = structuredDataHash( Cl.tuple({ - name: Cl.stringAscii("StackFlow"), + name: Cl.stringAscii(stackflowContract), version: Cl.stringAscii("0.6.0"), "chain-id": Cl.uint(chainIds.testnet), }) From 8d76f503ba4328f48a67bced96e3cd80bc673274 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 11 Feb 2026 06:56:33 -0500 Subject: [PATCH 27/78] feat: revised agent model Now, only contracts can register an agent. The agent still has full control over the contract's pipes. --- contracts/reservoir.clar | 48 ++++++++++- contracts/stackflow.clar | 81 ++++++++++++------- tests/reservoir.test.ts | 63 +++++++++++++++ tests/stackflow.test.ts | 170 +++++++++++++++++++-------------------- 4 files changed, 246 insertions(+), 116 deletions(-) diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index f9543e3..dceca1c 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -111,6 +111,39 @@ ) ) +;;; Set the signing agent used by this Reservoir contract for StackFlow +;;; signatures. +;;; Returns: +;;; - `(ok true)` on success +;;; - `ERR_UNAUTHORIZED` if the caller is not the operator +;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one +(define-public (set-agent + (stackflow ) + (agent principal) + ) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (try! (check-valid-stackflow stackflow)) + (ok (try! (as-contract? () (try! (contract-call? stackflow register-agent agent))))) + ) +) + +;;; Remove the signing agent used by this Reservoir contract. +;;; Returns: +;;; - `(ok true)` on success if an agent had been registered +;;; - `(ok false)` on success if no agent was registered +;;; - `ERR_UNAUTHORIZED` if the caller is not the operator +;;; - `ERR_NOT_INITIALIZED` if the contract has not been initialized +;;; - `ERR_INCORRECT_STACKFLOW` if the StackFlow contract is not the correct one +(define-public (clear-agent (stackflow )) + (begin + (asserts! (is-eq contract-caller OPERATOR) ERR_UNAUTHORIZED) + (try! (check-valid-stackflow stackflow)) + (ok (try! (as-contract? () (try! (contract-call? stackflow deregister-agent))))) + ) +) + ;;; As the operator, add `amount` of STX or FT `token` to the reservoir for ;;; borrowing. Providers must add at least the minimum liquidity amount. ;;; Returns: @@ -472,14 +505,23 @@ (stackflow ) (token (optional )) ) + (begin + (try! (check-valid-stackflow stackflow)) + (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) + ERR_UNAPPROVED_TOKEN + ) + (ok true) + ) +) + +;;; Check if the Reservoir is initialized and the correct StackFlow contract +;;; is passed. +(define-private (check-valid-stackflow (stackflow )) (begin (asserts! (var-get initialized) ERR_NOT_INITIALIZED) (asserts! (is-eq (some (contract-of stackflow)) (var-get stackflow-contract)) ERR_INCORRECT_STACKFLOW ) - (asserts! (is-eq (contract-of-optional token) (var-get supported-token)) - ERR_UNAPPROVED_TOKEN - ) (ok true) ) ) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index e8b0818..3159833 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -138,25 +138,26 @@ ) ) -;;; Register an agent to act on your behalf. Registering an agent allows you to -;;; transfer the responsibility of maintaining an always-on server for managing -;;; your pipes. The agent can perform all reactive actions on your behalf, -;;; including signing off on incoming transfers, deposit, withdraw, and closure -;;; requests from the other party, and disputing closures initiated by the -;;; other party. -;;; WARNING: An agent, collaborating with the other party, could potentially -;;; steal your funds. Only register agents you trust. +;;; Register a signing key for the calling contract principal. This is intended +;;; for contract participants (e.g. Reservoir contracts) that cannot produce +;;; signatures directly. Standard principals are not allowed to register agents. ;;; Returns `(ok true)` (define-public (register-agent (agent principal)) - (ok (map-set agents tx-sender agent)) + (begin + (asserts! (is-contract-principal contract-caller) ERR_UNAUTHORIZED) + (ok (map-set agents contract-caller agent)) + ) ) -;;; Deregister agent +;;; Deregister the signing key for the calling contract principal. ;;; Returns: ;;; - `(ok true)` if an agent had been registered ;;; - `(ok false)` if there was no agent registered (define-public (deregister-agent) - (ok (map-delete agents tx-sender)) + (begin + (asserts! (is-contract-principal contract-caller) ERR_UNAUTHORIZED) + (ok (map-delete agents contract-caller)) + ) ) ;;; Deposit `amount` funds into an unfunded pipe between `tx-sender` and @@ -499,14 +500,12 @@ ) ) -;;; As an agent of `for`, dispute the closing of a pipe that has been closed -;;; early by submitting a dispute within the waiting period. If the dispute is -;;; valid, the pipe will be closed and the new balances will be paid out to -;;; the appropriate parties. +;;; Dispute the closing of a pipe on behalf of `for` by submitting a dispute +;;; within the waiting period. This function is permissionless: any principal +;;; may submit valid signatures for `for`. ;;; Returns: ;;; - `(ok false)` on success if the pipe's token was STX ;;; - `(ok true)` on success if the pipe's token was a SIP-010 token -;;; - `ERR_UNAUTHORIZED` if the sender is not an agent of `for` ;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist ;;; - `ERR_NO_CLOSE_IN_PROGRESS` if a forced closure is not in progress ;;; - `ERR_SELF_DISPUTE` if the sender is disputing their own force closure @@ -517,7 +516,7 @@ ;;; - `ERR_INVALID_SENDER_SIGNATURE` if the sender's signature is invalid ;;; - `ERR_INVALID_OTHER_SIGNATURE` if the other party's signature is invalid ;;; - `ERR_WITHDRAWAL_FAILED` if the withdrawal fails -(define-public (agent-dispute-closure +(define-public (dispute-closure-for (for principal) (token (optional )) (with principal) @@ -531,11 +530,8 @@ (secret (optional (buff 32))) (valid-after (optional uint)) ) - (let ((agent (unwrap! (map-get? agents for) ERR_UNAUTHORIZED))) - (asserts! (is-eq tx-sender agent) ERR_UNAUTHORIZED) - (dispute-closure-inner for token with my-balance their-balance my-signature - their-signature nonce action actor secret valid-after - ) + (dispute-closure-inner for token with my-balance their-balance my-signature + their-signature nonce action actor secret valid-after ) ) @@ -552,8 +548,27 @@ (token (optional )) (with principal) ) + (finalize-inner tx-sender token with) +) + +;;; Finalize a pipe closure on behalf of `for`. This function is permissionless: +;;; anyone may finalize an expired closure. +(define-public (finalize-for + (for principal) + (token (optional )) + (with principal) + ) + (finalize-inner for token with) +) + +;;; Finalize a pipe closure for the pair (`for`, `with`), if expired. +(define-private (finalize-inner + (for principal) + (token (optional )) + (with principal) + ) (let ( - (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) + (pipe-key (try! (get-pipe-key (contract-of-optional token) for with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (closer (get closer pipe)) (expires-at (get expires-at pipe)) @@ -1126,7 +1141,7 @@ ) ) -;;; Inner function called by `dispute-closure` and `agent-dispute-closure`. +;;; Inner function called by `dispute-closure` and `dispute-closure-for`. (define-private (dispute-closure-inner (for principal) (token (optional )) @@ -1321,10 +1336,13 @@ (asserts! (or (is-eq recovered signer) - ;; Check if the signer is an agent of the actor - (match (map-get? agents signer) - agent (is-eq recovered agent) - false + ;; Contract principals may delegate signing to an agent key. + (and + (is-contract-principal signer) + (match (map-get? agents signer) + agent (is-eq recovered agent) + false + ) ) ) ERR_INVALID_SIGNATURE @@ -1333,6 +1351,13 @@ ) ) +;;; Determine whether a principal is a contract principal. +;;; Standard principals serialize to 22 bytes; contract principals include a +;;; contract-name suffix and therefore serialize to a longer buffer. +(define-private (is-contract-principal (p principal)) + (> (len (unwrap-panic (to-consensus-buff? p))) u22) +) + ;;; Check that the contract has been initialized and `token` is the supported token. (define-private (check-token (token (optional ))) (begin diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts index 7c8ed53..2b497f9 100644 --- a/tests/reservoir.test.ts +++ b/tests/reservoir.test.ts @@ -34,6 +34,69 @@ describe("reservoir", () => { ); }); + describe("agent management", () => { + it("operator can set the reservoir agent", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "set-agent", + [Cl.principal(stackflowContract), Cl.principal(address1)], + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + + const agent = simnet.getMapEntry( + stackflowContract, + "agents", + Cl.principal(reservoirContract) + ); + expect(agent).toBeSome(Cl.principal(address1)); + }); + + it("operator can clear the reservoir agent", () => { + simnet.callPublicFn( + "reservoir", + "set-agent", + [Cl.principal(stackflowContract), Cl.principal(address1)], + deployer + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "clear-agent", + [Cl.principal(stackflowContract)], + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + + const agent = simnet.getMapEntry( + stackflowContract, + "agents", + Cl.principal(reservoirContract) + ); + expect(agent).toBeNone(); + }); + + it("non-operator cannot set the reservoir agent", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "set-agent", + [Cl.principal(stackflowContract), Cl.principal(address1)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + + it("non-operator cannot clear the reservoir agent", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "clear-agent", + [Cl.principal(stackflowContract)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + }); + describe("borrow rate", () => { it("operator can set borrow rate", () => { const { result } = simnet.callPublicFn( diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index c64c94f..2cb6c2f 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -76,76 +76,26 @@ describe("init", () => { }); describe("register-agent", () => { - it("can register an agent", () => { + it("standard principal cannot register an agent", () => { const { result } = simnet.callPublicFn( "stackflow", "register-agent", [Cl.principal(address3)], address1 ); - expect(result).toBeOk(Cl.bool(true)); - - // Verify the map has been updated - const agent = simnet.getMapEntry( - stackflowContract, - "agents", - Cl.principal(address1) - ); - expect(agent).toBeSome(Cl.principal(address3)); - }); - - it("can overwrite an agent", () => { - const { result: result1 } = simnet.callPublicFn( - "stackflow", - "register-agent", - [Cl.principal(address3)], - address1 - ); - expect(result1).toBeOk(Cl.bool(true)); - - const { result } = simnet.callPublicFn( - "stackflow", - "register-agent", - [Cl.principal(address2)], - address1 - ); - expect(result).toBeOk(Cl.bool(true)); - - // Verify the map has been updated - const agent = simnet.getMapEntry( - stackflowContract, - "agents", - Cl.principal(address1) - ); - expect(agent).toBeSome(Cl.principal(address2)); + expect(result).toBeErr(Cl.uint(StackflowError.Unauthorized)); }); }); describe("deregister-agent", () => { - it("can deregister an agent", () => { - const { result: result1 } = simnet.callPublicFn( - "stackflow", - "register-agent", - [Cl.principal(address3)], - address1 - ); - expect(result1).toBeOk(Cl.bool(true)); - + it("standard principal cannot deregister an agent", () => { const { result } = simnet.callPublicFn( "stackflow", "deregister-agent", [], address1 ); - expect(result).toBeOk(Cl.bool(true)); - - // Verify the map has been updated - const agent = simnet.getMapEntry( - stackflowContract, - "agents", - Cl.principal(address1) - ); - expect(agent).toBeNone(); + expect(result).toBeErr(Cl.uint(StackflowError.Unauthorized)); }); }); @@ -2868,18 +2818,10 @@ describe("dispute-closure", () => { expect(contractBalance).toBe(3000000n); }); - it("account 2 can dispute account 1's closure with an agent-signed transfer", () => { + it("account 2 cannot dispute with a standard-principal agent-signed transfer", () => { // Initialize the contract for STX simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); - // Register an agent for address 1 - simnet.callPublicFn( - "stackflow", - "register-agent", - [Cl.principal(address3)], - address1 - ); - // Setup the pipe and save the pipe key simnet.callPublicFn( "stackflow", @@ -2952,37 +2894,37 @@ describe("dispute-closure", () => { ], address2 ); - expect(disputeResult).toBeOk(Cl.bool(false)); + expect(disputeResult).toBeErr(Cl.uint(StackflowError.InvalidOtherSignature)); - // Verify that the pipe has been reset + // Verify that the pipe is unchanged const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); expect(pipe).toBeSome( Cl.tuple({ - "balance-1": Cl.uint(0), - "balance-2": Cl.uint(0), - "expires-at": Cl.uint(MAX_HEIGHT), - nonce: Cl.uint(1), - closer: Cl.none(), + "balance-1": Cl.uint(1000000), + "balance-2": Cl.uint(2000000), + "expires-at": Cl.uint(cancel_height + WAITING_PERIOD), + nonce: Cl.uint(0), + closer: Cl.some(Cl.principal(address1)), "pending-1": Cl.none(), "pending-2": Cl.none(), }) ); - // Verify the balances have changed + // Verify the balances have not changed const stxBalances = simnet.getAssetsMap().get("STX")!; const balance1 = stxBalances.get(address1); - expect(balance1).toBe(100000000300000n); + expect(balance1).toBe(99999999000000n); const balance2 = stxBalances.get(address2); - expect(balance2).toBe(99999999700000n); + expect(balance2).toBe(99999998000000n); const contractBalance = stxBalances.get(stackflowContract); - expect(contractBalance).toBe(0n); + expect(contractBalance).toBe(3000000n); }); }); -describe("agent-dispute-closure", () => { +describe("dispute-closure-for", () => { it("disputing a non-existent pipe gives an error", () => { // Initialize the contract for STX simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); @@ -3036,7 +2978,7 @@ describe("agent-dispute-closure", () => { const { result } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address1), Cl.none(), @@ -3114,7 +3056,7 @@ describe("agent-dispute-closure", () => { // Account 2 disputes the closure const { result: disputeResult } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address2), Cl.none(), @@ -3238,7 +3180,7 @@ describe("agent-dispute-closure", () => { // Account 2 disputes the closure const { result: disputeResult } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address2), Cl.none(), @@ -3352,7 +3294,7 @@ describe("agent-dispute-closure", () => { // Account 1 disputes the closure const { result: disputeResult } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address1), Cl.none(), @@ -3468,7 +3410,7 @@ describe("agent-dispute-closure", () => { // Account 1 disputes the closure const { result: disputeResult } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address1), Cl.none(), @@ -3688,6 +3630,64 @@ describe("finalize", () => { expect(contractBalance).toBe(0n); }); + it("a third party can finalize-for after waiting period", () => { + // Initialize the contract for STX + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + // Setup the pipe + const { result: fundResult } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + expect(fundResult.type).toBe(ClarityType.ResponseOk); + const pipeKey = (fundResult as ResponseOkCV).value; + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + + // Wait for the funds to confirm and force-cancel + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + const cancelHeight = simnet.burnBlockHeight; + const { result: cancelResult } = simnet.callPublicFn( + "stackflow", + "force-cancel", + [Cl.none(), Cl.principal(address2)], + address1 + ); + expect(cancelResult).toBeOk(Cl.uint(cancelHeight + WAITING_PERIOD)); + + // Wait until finalize is valid + simnet.mineEmptyBurnBlocks(WAITING_PERIOD + 1); + + // A third party finalizes the closure + const { result } = simnet.callPublicFn( + "stackflow", + "finalize-for", + [Cl.principal(address1), Cl.none(), Cl.principal(address2)], + address3 + ); + expect(result).toBeOk(Cl.bool(false)); + + // Verify the pipe has been reset + const pipe = simnet.getMapEntry(stackflowContract, "pipes", pipeKey); + expect(pipe).toBeSome( + Cl.tuple({ + "balance-1": Cl.uint(0), + "balance-2": Cl.uint(0), + "expires-at": Cl.uint(MAX_HEIGHT), + nonce: Cl.uint(0), + closer: Cl.none(), + "pending-1": Cl.none(), + "pending-2": Cl.none(), + }) + ); + }); + it("account 1 can finalize account 2's cancel", () => { // Initialize the contract for STX simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); @@ -5577,8 +5577,8 @@ describe("multiple deposits and withdrawals", () => { }); }); -describe("agent-dispute additional tests", () => { - it("agent-dispute-closure fails when agent not registered", () => { +describe("dispute-closure-for additional tests", () => { + it("dispute-closure-for is permissionless but still enforces nonce rules", () => { // Initialize the contract for STX simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); @@ -5640,10 +5640,10 @@ describe("agent-dispute additional tests", () => { address1 ); - // Try to dispute with unregistered agent + // Try to dispute with the same nonce as the existing forced close const { result } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address2), Cl.none(), @@ -5661,7 +5661,7 @@ describe("agent-dispute additional tests", () => { address3 ); - expect(result).toBeErr(Cl.uint(StackflowError.Unauthorized)); + expect(result).toBeErr(Cl.uint(StackflowError.NonceTooLow)); }); }); From 6250195baa6e97deb07d546de9f4445557d2c261 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 18 Feb 2026 09:23:37 -0500 Subject: [PATCH 28/78] refactor: consolidate checks Use same code that is used to check signatures in real contract-calls to also check signatures with the read-only function. This ensures any server that needs to authenticate signatures will remain in sync with the implementation in the contract. --- .gitignore | 3 +- Clarinet.toml | 3 - README.md | 177 +++++++ contracts/stackflow.clar | 332 ++++++++----- deployments/default.simnet-plan.yaml | 8 - package-lock.json | 690 +++++++++++++++++++++++++++ package.json | 14 +- settings/Devnet.toml | 4 +- tests/stackflow.test.ts | 99 ++++ tsconfig.json | 4 +- 10 files changed, 1188 insertions(+), 146 deletions(-) diff --git a/.gitignore b/.gitignore index 6ca17dc..230017b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ costs-reports.json node_modules .debug_history **/.env - +server/data/*.json +server/dist/ diff --git a/Clarinet.toml b/Clarinet.toml index 44a6cc7..1268784 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -8,9 +8,6 @@ cache_dir = './.cache' [[project.requirements]] contract_id = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard' -[[project.requirements]] -contract_id = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.sip-010-trait-ft-standard' - [contracts.reservoir] path = 'contracts/reservoir.clar' clarity_version = 4 diff --git a/README.md b/README.md index 2f60ef4..bbcd6fc 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,183 @@ If the closure is not disputed by the time the waiting period is over, the user may call `finalize` to complete the closure and transfer the appropriate balances to both parties. +# Built-in Watchtower (Event Observer) + +This repo now includes a minimal watchtower service at `server/src/index.ts`. It +is designed to run as a stacks-node event observer, ingest `print` events from +Stackflow contracts, store latest signed states, and auto-submit +`dispute-closure-for` when a `force-close` or `force-cancel` is observed. + +Run it with: + +```bash +npm run watchtower +``` + +Optional environment variables: + +```bash +WATCHTOWER_HOST=0.0.0.0 +WATCHTOWER_PORT=8787 +WATCHTOWER_DB_FILE=server/data/watchtower-state.db +WATCHTOWER_MAX_RECENT_EVENTS=500 +WATCHTOWER_LOG_RAW_EVENTS=false +STACKFLOW_CONTRACTS=ST....stackflow-0-6-0,ST....stackflow-sbtc-0-6-0 +WATCHTOWER_PRINCIPALS=ST...,ST... +STACKS_NETWORK=devnet +STACKS_API_URL=http://localhost:20443 +WATCHTOWER_SIGNER_KEY= +WATCHTOWER_PRODUCER_KEY= +WATCHTOWER_PRODUCER_PRINCIPAL= +WATCHTOWER_PRODUCER_SIGNER_MODE=local-key +WATCHTOWER_STACKFLOW_MESSAGE_VERSION=0.6.0 +WATCHTOWER_SIGNATURE_VERIFIER_MODE=readonly +WATCHTOWER_DISPUTE_EXECUTOR_MODE=auto +WATCHTOWER_DISPUTE_ONLY_BENEFICIAL=false +``` + +If `STACKFLOW_CONTRACTS` is omitted, the watchtower automatically monitors any +contract identifier matching `*.stackflow*`. +`WATCHTOWER_STATE_FILE` is still accepted as a backward-compatible alias for +`WATCHTOWER_DB_FILE`. +The current implementation uses Node's `node:sqlite` module for persistence. +`WATCHTOWER_SIGNATURE_VERIFIER_MODE` supports `readonly` (default), +`accept-all`, and `reject-all`. Non-`readonly` modes are intended for testing. +`WATCHTOWER_DISPUTE_EXECUTOR_MODE` supports `auto` (default), `noop`, and +`mock`. `mock` is intended for local integration testing. +`WATCHTOWER_PRODUCER_SIGNER_MODE` currently supports `local-key` (default) and +`kms` (reserved for future signer backends; currently returns `503`). +Set `WATCHTOWER_LOG_RAW_EVENTS=true` to print raw stackflow `print` event +objects received via `/new_block` for payload inspection/debugging. +If `WATCHTOWER_PRINCIPALS` is set, the watchtower only: + +1. accepts `POST /signature-states` for `forPrincipal` values in that list +2. processes closure events for pipes that include at least one listed principal + +When `WATCHTOWER_PRINCIPALS` is omitted, it accepts any principal. + +Health and inspection endpoints: + +1. `GET /health` +2. `GET /closures` +3. `GET /pipes?limit=100&principal=ST...` +4. `GET /signature-states?limit=100` +5. `GET /dispute-attempts?limit=100` +6. `GET /events?limit=100` +7. `GET /app` (built-in browser UI) + +Producer signing endpoints: + +1. `POST /producer/transfer` +2. `POST /producer/signature-request` + +`/producer/transfer` signs transfer states (`action = 1`). +`/producer/signature-request` signs close/deposit/withdraw states +(`action = 0|2|3`). +For `action = 2|3` (deposit/withdraw), include `amount` in the request body. +Both endpoints: + +1. check local signing policy against stored state: + - reject if nonce is not strictly higher than latest known nonce + - reject if producer balance would decrease + - for transfer (`action = 1`), require producer balance to strictly increase +2. verify the counterparty signature (`verify-signature-request`) +3. generate the producer signature +4. store the full signature pair via the existing signature-state pipeline + +Producer signature verification uses `verify-signature-request` (read-only) to +apply action-aware on-chain balance logic, including `amount` checks for +deposit/withdraw requests. + +Signature state ingestion endpoint: + +1. `POST /signature-states` + +Example payload: + +```json +{ + "contractId": "ST....stackflow-0-6-0", + "forPrincipal": "ST...", + "withPrincipal": "ST...", + "token": null, + "myBalance": "900000", + "theirBalance": "100000", + "mySignature": "0x...", + "theirSignature": "0x...", + "nonce": "42", + "action": "1", + "actor": "ST...", + "secret": null, + "validAfter": null, + "beneficialOnly": false +} +``` + +The watchtower stores one latest state per `(contract, pipe, forPrincipal)`, +replacing only when the incoming nonce is strictly greater. +Before storing, the watchtower verifies signatures by calling the Stackflow +contract read-only function `verify-signatures`. If validation fails, the +request returns `401` and nothing is stored. +If the incoming nonce is not strictly higher than the stored nonce for that +`(contract, pipe, forPrincipal)`, the request returns `409`. +If `forPrincipal` is not in `WATCHTOWER_PRINCIPALS`, the request returns `403`. + +On-chain pipe tracking: + +1. `POST /new_block` print events update a persistent `pipes` view +2. events such as `fund-pipe`, `deposit`, `withdraw`, `force-cancel`, and + `force-close` upsert current pipe balances +3. terminal events (`close-pipe`, `dispute-closure`, `finalize`) reset tracked + balances to `0` and clear pending values +4. `POST /new_burn_block` advances pending deposits into confirmed balances + once pending `burn-height` is reached +5. `GET /pipes` merges this on-chain view with stored signature states and + returns the authoritative state per pipe by highest nonce (ties prefer + newer timestamps, then on-chain source) + +Event observer ingestion endpoint: + +1. `POST /new_block` +2. `POST /new_burn_block` + +When Clarinet/stacks-node observer config uses `events_keys = ["*"]`, stacks-node +can also call additional observer endpoints. The watchtower responds `200` (no-op) +for compatibility on: + +1. `POST /new_mempool_tx` +2. `POST /drop_mempool_tx` +3. `POST /new_microblocks` + +For Clarinet devnet, set the observer in `settings/Devnet.toml`: + +```toml +[devnet] +stacks_node_events_observers = ["host.docker.internal:8787"] +``` + +Open the built-in UI in your browser: + +```text +http://localhost:8787/app +``` + +The UI lets you: + +1. connect a Stacks wallet +2. load watched pipes from on-chain observer state (`GET /pipes`) +3. generate SIP-018 structured signatures for Stackflow state +4. submit signature states to `POST /signature-states` +5. call Stackflow contract methods (`fund-pipe`, `deposit`, `withdraw`, + `force-cancel`, `force-close`, `close-pipe`, `finalize`) via wallet popup + +Integration tests for the HTTP server are opt-in (they spawn a real process and +bind a local port): + +```bash +npm run test:watchtower:http +``` + # Reference Server Implementation As discussed in the details, users of Stackflow should run a server to keep diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 3159833..25bcff2 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -79,6 +79,7 @@ (define-constant ERR_INVALID_BALANCES (err u124)) (define-constant ERR_INVALID_SIGNATURE (err u125)) (define-constant ERR_ALLOWANCE_VIOLATION (err u126)) +(define-constant ERR_SELF_PIPE (err u127)) ;; Number of burn blocks to wait before considering an on-chain action finalized. (define-constant CONFIRMATION_DEPTH u6) @@ -181,6 +182,7 @@ ) (begin (try! (check-token token)) + (asserts! (not (is-eq tx-sender with)) ERR_SELF_PIPE) (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (existing-pipe (map-get? pipes pipe-key)) @@ -246,8 +248,6 @@ ) (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) - (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) - (pipe-nonce (get nonce pipe)) (principal-1 (get principal-1 pipe-key)) (balance-1 (if (is-eq tx-sender principal-1) my-balance @@ -264,28 +264,10 @@ nonce: nonce, closer: none, }) - (settled-pipe (settle-pending pipe-key pipe)) - ) - ;; Cannot close a pipe while there is a pending deposit - (asserts! - (and - (is-none (get pending-1 settled-pipe)) - (is-none (get pending-2 settled-pipe)) - ) - ERR_PENDING - ) - - ;; The nonce must be greater than the pipe's saved nonce - (asserts! (> nonce pipe-nonce) ERR_NONCE_TOO_LOW) - - ;; If the total balance of the pipe is not equal to the sum of the - ;; balances provided, the pipe close is invalid. - (asserts! - (is-eq (+ my-balance their-balance) - (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) - ) - ERR_INVALID_TOTAL_BALANCE ) + (try! (validate-transition pipe-key balance-1 balance-2 nonce ACTION_CLOSE + tx-sender u0 none + )) ;; Verify the signatures of the two parties. (try! (verify-signatures my-signature tx-sender their-signature with pipe-key @@ -622,12 +604,7 @@ (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) - (pipe-nonce (get nonce pipe)) - (closer (get closer pipe)) (principal-1 (get principal-1 pipe-key)) - ;; Ensure that the balance of the caller is not less than the deposit - ;; amount, since that would indicate an invalid deposit. - (balance-ok (asserts! (>= my-balance amount) ERR_INVALID_BALANCES)) ;; These are the balances that both parties have signed off on, including ;; the deposit amount. (balance-1 (if (is-eq tx-sender principal-1) @@ -639,93 +616,58 @@ my-balance )) (settled-pipe (settle-pending pipe-key pipe)) - ;; If the new balance of the pipe is not equal to the sum of the - ;; existing balances and the deposit amount, the deposit is invalid. - ;; Previously pending balances are included in the calculation. - (total-ok (asserts! - (is-eq (+ my-balance their-balance) - (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe) - (match (get pending-1 settled-pipe) - pending (get amount pending) - u0 - ) - (match (get pending-2 settled-pipe) - pending (get amount pending) - u0 - ) - amount - )) - ERR_INVALID_TOTAL_BALANCE - )) - ;; These are the settled balances that actually exist in the pipe while - ;; the deposit is pending. - (pre-balance-1 (if (is-eq tx-sender principal-1) - (- my-balance amount) - (- their-balance - (match (get pending-1 settled-pipe) - pending (get amount pending) - u0 - )) + (pending-1-amount (match (get pending-1 settled-pipe) + pending (get amount pending) + u0 )) - (pre-balance-2 (if (is-eq tx-sender principal-1) - (- their-balance - (match (get pending-2 settled-pipe) - pending (get amount pending) - u0 - )) - (- my-balance amount) + (pending-2-amount (match (get pending-2 settled-pipe) + pending (get amount pending) + u0 )) - (updated-pipe (try! (increase-sender-balance pipe-key settled-pipe token amount))) - (result-pipe (merge updated-pipe { - balance-1: pre-balance-1, - balance-2: pre-balance-2, - nonce: nonce, - })) ) - ;; A forced closure must not be in progress - (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) - - ;; Nonce must be greater than the pipe nonce - (asserts! (> nonce pipe-nonce) ERR_NONCE_TOO_LOW) + ;; Validate the same transition constraints used by read-only verification. + (try! (validate-transition pipe-key balance-1 balance-2 nonce ACTION_DEPOSIT + tx-sender amount none + )) - ;; If the new balance of the pipe is not equal to the sum of the - ;; existing balances and the deposit amount, the deposit is invalid. - ;; Previously pending balances are included in the calculation. - (asserts! - (is-eq (+ my-balance their-balance) - (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe) - (match (get pending-1 settled-pipe) - pending (get amount pending) - u0 - ) - (match (get pending-2 settled-pipe) - pending (get amount pending) - u0 - ) - amount + (let ( + ;; These are the settled balances that actually exist in the pipe while + ;; the deposit is pending. + (pre-balance-1 (if (is-eq tx-sender principal-1) + (- my-balance amount) + (- their-balance pending-1-amount) )) - ERR_INVALID_TOTAL_BALANCE - ) - - ;; Update the pipe with the new balances and nonce. - (map-set pipes pipe-key result-pipe) + (pre-balance-2 (if (is-eq tx-sender principal-1) + (- their-balance pending-2-amount) + (- my-balance amount) + )) + (updated-pipe (try! (increase-sender-balance pipe-key settled-pipe token amount))) + (result-pipe (merge updated-pipe { + balance-1: pre-balance-1, + balance-2: pre-balance-2, + nonce: nonce, + })) + ) + ;; Update the pipe with the new balances and nonce. + (map-set pipes pipe-key result-pipe) - ;; Verify the signatures of the two parties. - (try! (verify-signatures my-signature tx-sender their-signature with pipe-key - balance-1 balance-2 nonce ACTION_DEPOSIT tx-sender none none - )) + ;; Verify the signatures of the two parties. + (try! (verify-signatures my-signature tx-sender their-signature with pipe-key + balance-1 balance-2 nonce ACTION_DEPOSIT tx-sender none none + )) - (print { - event: "deposit", - pipe-key: pipe-key, - pipe: updated-pipe, - sender: tx-sender, - amount: amount, - my-signature: my-signature, - their-signature: their-signature, - }) + (print { + event: "deposit", + pipe-key: pipe-key, + pipe: updated-pipe, + sender: tx-sender, + amount: amount, + my-signature: my-signature, + their-signature: their-signature, + }) - (ok pipe-key) + (ok pipe-key) + ) ) ) @@ -755,8 +697,6 @@ (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) - (pipe-nonce (get nonce pipe)) - (closer (get closer pipe)) (principal-1 (get principal-1 pipe-key)) (balance-1 (if (is-eq tx-sender principal-1) my-balance @@ -774,26 +714,10 @@ nonce: nonce, })) ) - ;; A forced closure must not be in progress - (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) - - ;; Nonce must be greater than the pipe nonce - (asserts! (> nonce pipe-nonce) ERR_NONCE_TOO_LOW) - - ;; Withdrawal amount cannot be greater than the total pipe balance - (asserts! - (>= (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) amount) - ERR_INVALID_WITHDRAWAL - ) - - ;; If the new balance of the pipe is not equal to the sum of the - ;; prior balances minus the withdraw amount, the withdrawal is invalid. - (asserts! - (is-eq (+ my-balance their-balance) - (- (+ (get balance-1 settled-pipe) (get balance-2 settled-pipe)) amount) - ) - ERR_INVALID_TOTAL_BALANCE - ) + ;; Validate the same transition constraints used by read-only verification. + (try! (validate-transition pipe-key balance-1 balance-2 nonce ACTION_WITHDRAWAL + tx-sender amount none + )) ;; Update the pipe with the new balances and nonce. (map-set pipes pipe-key updated-pipe) @@ -967,6 +891,53 @@ ) ) +;;; Validates that the specified data is valid for the action and that +;;; `signature` is a valid signature from `signer` for this data. +;;; For `ACTION_DEPOSIT` and `ACTION_WITHDRAWAL`, `amount` is required to match +;;; the same balance equations enforced by the corresponding public functions. +;;; Returns: +;;; - `(ok none)` if the signature is valid now +;;; - `(ok (some burn-block))` if the signature will be valid at `burn-block` +;;; - Same error semantics as `verify-signature-with-secret`, with action- +;;; specific balance checks for deposit and withdrawal. +(define-read-only (verify-signature-request + (signature (buff 65)) + (signer principal) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) + (balance-1 uint) + (balance-2 uint) + (nonce uint) + (action uint) + (actor principal) + (secret (optional (buff 32))) + (valid-after (optional uint)) + (amount uint) + ) + (let ( + (hashed-secret (match secret + s (some (sha256 s)) + none + )) + (hash (try! (make-structured-data-hash pipe-key balance-1 balance-2 nonce action actor + hashed-secret valid-after + ))) + (after (default-to burn-block-height valid-after)) + ) + (try! (validate-transition pipe-key balance-1 balance-2 nonce action actor amount + valid-after + )) + (try! (verify-hash-signature hash signature signer actor)) + (if (> after burn-block-height) + (ok (some (- after burn-block-height))) + (ok none) + ) + ) +) + ;;; Validates that `signature-1` and `signature-2` are valid signature from ;;; `signer-1` and `signer-2`, respectively, for the structured data ;;; constructed from the other arguments. @@ -1486,6 +1457,109 @@ ) ) +;;; Check action-specific balance invariants for signature validation. +;;; - For `ACTION_DEPOSIT`, validates the same total-balance and pending rules +;;; enforced by `deposit`. +;;; - For `ACTION_WITHDRAWAL`, validates the same total-balance rules enforced +;;; by `withdraw`. +;;; - For `ACTION_CLOSE`, validates the same pending and total-balance rules +;;; enforced by `close-pipe`. +;;; - For other actions, defers to `balance-check`. +(define-private (action-balance-check + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) + (balance-1 uint) + (balance-2 uint) + (action uint) + (actor principal) + (amount uint) + (at-height-opt (optional uint)) + ) + (let ( + (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) + (at-height (default-to burn-block-height at-height-opt)) + (pipe-1 (get balance-1 pipe)) + (pipe-2 (get balance-2 pipe)) + (pipe-pending-1 (get pending-1 pipe)) + (pipe-pending-2 (get pending-2 pipe)) + (pipe-balances-1 (calculate-balances pipe-1 pipe-pending-1 at-height)) + (pipe-balances-2 (calculate-balances pipe-2 pipe-pending-2 at-height)) + (confirmed (+ (get confirmed pipe-balances-1) (get confirmed pipe-balances-2))) + (pending (+ (get pending pipe-balances-1) (get pending pipe-balances-2))) + (pipe-total-sum (+ confirmed pending)) + (sum (+ balance-1 balance-2)) + (principal-1 (get principal-1 pipe-key)) + (principal-2 (get principal-2 pipe-key)) + (actor-balance (if (is-eq actor principal-1) + balance-1 + balance-2 + )) + (actor-pending (if (is-eq actor principal-1) + (get pending pipe-balances-1) + (get pending pipe-balances-2) + )) + ) + (if (is-eq action ACTION_DEPOSIT) + (begin + (asserts! (or (is-eq actor principal-1) (is-eq actor principal-2)) + ERR_INVALID_PRINCIPAL + ) + (asserts! (is-none (get closer pipe)) ERR_CLOSE_IN_PROGRESS) + (asserts! (>= actor-balance amount) ERR_INVALID_BALANCES) + (asserts! (is-eq actor-pending u0) ERR_ALREADY_PENDING) + (asserts! (is-eq sum (+ pipe-total-sum amount)) ERR_INVALID_TOTAL_BALANCE) + (ok true) + ) + (if (is-eq action ACTION_WITHDRAWAL) + (begin + (asserts! (or (is-eq actor principal-1) (is-eq actor principal-2)) + ERR_INVALID_PRINCIPAL + ) + (asserts! (is-none (get closer pipe)) ERR_CLOSE_IN_PROGRESS) + (asserts! (>= confirmed amount) ERR_INVALID_WITHDRAWAL) + (asserts! (is-eq sum (- confirmed amount)) ERR_INVALID_TOTAL_BALANCE) + (ok true) + ) + (if (is-eq action ACTION_CLOSE) + (begin + (asserts! (is-eq pending u0) ERR_PENDING) + (asserts! (is-eq sum confirmed) ERR_INVALID_TOTAL_BALANCE) + (ok true) + ) + (balance-check pipe-key balance-1 balance-2 at-height-opt) + ) + ) + ) + ) +) + +;;; Shared transition validation used by both public state-changing functions +;;; and read-only signature verification. +(define-private (validate-transition + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) + (balance-1 uint) + (balance-2 uint) + (nonce uint) + (action uint) + (actor principal) + (amount uint) + (at-height-opt (optional uint)) + ) + (begin + (try! (nonce-check pipe-key nonce)) + (action-balance-check pipe-key balance-1 balance-2 action actor amount + at-height-opt + ) + ) +) + ;;; Given the current confirmed balance and an optional pending deposit, ;;; calculate the confirmed and pending balances at the current block height. ;;; Returns a tuple with the confirmed balance and the pending balance. diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index d990d2b..190dff2 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -70,14 +70,6 @@ plan: clarity-version: 1 epoch: "2.1" - id: 1 - transactions: - - emulated-contract-publish: - contract-name: sip-010-trait-ft-standard - emulated-sender: ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J - path: "./.cache/requirements/ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.sip-010-trait-ft-standard.clar" - clarity-version: 3 - epoch: "3.1" - - id: 2 transactions: - emulated-contract-publish: contract-name: stackflow-token diff --git a/package-lock.json b/package-lock.json index 667ff6e..3a9cbd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@stacks/clarinet-sdk": "^3.10.0", + "@stacks/connect": "^7.2.0", "@stacks/network": "^7.2.0", "@stacks/transactions": "^7.2.0", "chokidar-cli": "^3.0.0", @@ -18,6 +19,10 @@ "vite": "^6.2.6", "vitest": "^3.1.1", "vitest-environment-clarinet": "^3.0.2" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "esbuild": "^0.25.12" } }, "node_modules/@esbuild/aix-ppc64": { @@ -752,11 +757,86 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.1.0.tgz", + "integrity": "sha512-pwrPOS16VeTKg98dYXQyIjJEcWfz7/1YJIwxUEPFfQPtc86Ym/1sVgQ2RLoD43AazMk2l/unK4ITySSpW2+82w==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.1.1", + "@scure/base": "~1.1.0" + } + }, + "node_modules/@stacks/auth": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-6.17.0.tgz", + "integrity": "sha512-SaxB6ULkYLRd5WZotymlPzroBn5/28KgJOTY0nKDcwCqxSkYjPZepweA30LK5eUOmePuGILaMTagj1ibZRnvUg==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.16.0", + "@stacks/encryption": "^6.17.0", + "@stacks/network": "^6.17.0", + "@stacks/profile": "^6.17.0", + "cross-fetch": "^3.1.5", + "jsontokens": "^4.0.1" + } + }, + "node_modules/@stacks/auth/node_modules/@stacks/common": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", + "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", + "license": "MIT", + "dependencies": { + "@types/bn.js": "^5.1.0", + "@types/node": "^18.0.4" + } + }, + "node_modules/@stacks/auth/node_modules/@stacks/network": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", + "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.16.0", + "cross-fetch": "^3.1.5" + } + }, + "node_modules/@stacks/auth/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@stacks/auth/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@stacks/clarinet-sdk": { "version": "3.11.0", "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk/-/clarinet-sdk-3.11.0.tgz", "integrity": "sha512-oiZ+x9PibUg4N+CNGSdana/5WRPMll77CGPaiN3Jimcrg0jjYyAYn4Lt6WgYUxxuAj9uLR8JiM5/PetmFx3itw==", "license": "GPL-3.0", + "peer": true, "dependencies": { "@stacks/clarinet-sdk-wasm": "3.11.0", "@stacks/transactions": "^7.0.6", @@ -780,6 +860,121 @@ "integrity": "sha512-+RSecHdkxOtswmE4tDDoZlYEuULpnTQVeDIG5eZ32opK8cFxf4EugAcK9CsIsHx/Se1yTEaQ21WGATmJGK84lQ==", "license": "MIT" }, + "node_modules/@stacks/connect": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@stacks/connect/-/connect-7.2.0.tgz", + "integrity": "sha512-3Y4bO31yCp0Cmf/W+fYxjRbONNK6Rb5ZaRxqfqgO+wKxHrYr1VoZobv109HF/z14a4zCECvvfqXVU6sls+FCXQ==", + "license": "MIT", + "dependencies": { + "@stacks/auth": "^6.1.1", + "@stacks/connect-ui": "6.0.1", + "@stacks/network": "^6.1.1", + "@stacks/profile": "^6.1.1", + "@stacks/transactions": "^6.1.1", + "jsontokens": "^4.0.1", + "url": "^0.11.0" + } + }, + "node_modules/@stacks/connect-ui": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@stacks/connect-ui/-/connect-ui-6.0.1.tgz", + "integrity": "sha512-DOB2UdwLJAznHfsOmloTzK7JDIfxwUq+GqEH6z0snxA3Gsu2aernlLhwUW1QLFXQtPw/fUp1ty+re71qHUc6tg==", + "license": "MIT", + "dependencies": { + "@stencil/core": "^2.17.1" + } + }, + "node_modules/@stacks/connect/node_modules/@stacks/common": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", + "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", + "license": "MIT", + "dependencies": { + "@types/bn.js": "^5.1.0", + "@types/node": "^18.0.4" + } + }, + "node_modules/@stacks/connect/node_modules/@stacks/network": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", + "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.16.0", + "cross-fetch": "^3.1.5" + } + }, + "node_modules/@stacks/connect/node_modules/@stacks/transactions": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.17.0.tgz", + "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^6.16.0", + "@stacks/network": "^6.17.0", + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" + } + }, + "node_modules/@stacks/connect/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@stacks/connect/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/@stacks/encryption": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.17.0.tgz", + "integrity": "sha512-c0+ZOjrAiB1fDCjXO6XqHdYgpeBeMYyeH+dWahpD1VQUDor2PE5Q47qyuibWmx36rLWt1M6wlaLdeVm6HlKGzw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@scure/bip39": "1.1.0", + "@stacks/common": "^6.16.0", + "@types/node": "^18.0.4", + "base64-js": "^1.5.1", + "bs58": "^5.0.0", + "ripemd160-min": "^0.0.6", + "varuint-bitcoin": "^1.1.2" + } + }, + "node_modules/@stacks/encryption/node_modules/@stacks/common": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", + "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", + "license": "MIT", + "dependencies": { + "@types/bn.js": "^5.1.0", + "@types/node": "^18.0.4" + } + }, + "node_modules/@stacks/encryption/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@stacks/encryption/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@stacks/network": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.2.0.tgz", @@ -790,6 +985,69 @@ "cross-fetch": "^3.1.5" } }, + "node_modules/@stacks/profile": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-6.17.0.tgz", + "integrity": "sha512-EoYe0NapFc6bgA+vyCVY2sYYRHk3pbsbRnm3eaSp8y9Drfy8dBqsM10W1jjTwOn0R+IMmDT52lojdW7Pw3c7Mw==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.16.0", + "@stacks/network": "^6.17.0", + "@stacks/transactions": "^6.17.0", + "jsontokens": "^4.0.1", + "schema-inspector": "^2.0.2", + "zone-file": "^2.0.0-beta.3" + } + }, + "node_modules/@stacks/profile/node_modules/@stacks/common": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", + "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", + "license": "MIT", + "dependencies": { + "@types/bn.js": "^5.1.0", + "@types/node": "^18.0.4" + } + }, + "node_modules/@stacks/profile/node_modules/@stacks/network": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", + "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.16.0", + "cross-fetch": "^3.1.5" + } + }, + "node_modules/@stacks/profile/node_modules/@stacks/transactions": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.17.0.tgz", + "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^6.16.0", + "@stacks/network": "^6.17.0", + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" + } + }, + "node_modules/@stacks/profile/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@stacks/profile/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@stacks/transactions": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.3.0.tgz", @@ -804,6 +1062,28 @@ "lodash.clonedeep": "^4.5.0" } }, + "node_modules/@stencil/core": { + "version": "2.22.3", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.3.tgz", + "integrity": "sha512-kmVA0M/HojwsfkeHsifvHVIYe4l5tin7J5+DLgtl8h6WWfiMClND5K3ifCXXI2ETDNKiEk21p6jql3Fx9o2rng==", + "license": "MIT", + "bin": { + "stencil": "bin/stencil" + }, + "engines": { + "node": ">=12.10.0", + "npm": ">=6.0.0" + } + }, + "node_modules/@types/bn.js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.2.0.tgz", + "integrity": "sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -826,6 +1106,15 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -980,12 +1269,41 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, "node_modules/base-x": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1010,6 +1328,15 @@ "node": ">=8" } }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, "node_modules/c32check": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/c32check/-/c32check-2.0.0.tgz", @@ -1032,6 +1359,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1305,18 +1661,62 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1423,6 +1823,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1444,6 +1853,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1456,6 +1902,42 @@ "node": ">= 6" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1513,6 +1995,17 @@ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "license": "MIT" }, + "node_modules/jsontokens": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsontokens/-/jsontokens-4.0.1.tgz", + "integrity": "sha512-+MO415LEN6M+3FGsRz4wU20g7N2JA+2j9d9+pGaNJHviG4L8N0qzavGyENw6fJqsq9CcrHOIL6iWX5yeTZ86+Q==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.2", + "@noble/secp256k1": "^1.6.3", + "base64-js": "^1.5.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -1541,6 +2034,12 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -1574,6 +2073,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1627,6 +2135,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -1746,6 +2266,27 @@ "node": ">= 6" } }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1773,6 +2314,14 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/ripemd160-min": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/ripemd160-min/-/ripemd160-min-0.0.6.tgz", + "integrity": "sha512-+GcJgQivhs6S9qvLogusiTcS9kQUfgR75whKuy5jIhuiOfQuJ8fjqxV6EGD5duH1Y/FawFUMtMhyeq3Fbnib8A==", + "engines": { + "node": ">=8" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -1814,12 +2363,113 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/schema-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/schema-inspector/-/schema-inspector-2.1.0.tgz", + "integrity": "sha512-3bmQVhbA01/EW8cZin4vIpqlpNU2SIy4BhKCfCgogJ3T/L76dLx3QAE+++4o+dNT33sa+SN9vOJL7iHiHFjiNg==", + "license": "MIT", + "dependencies": { + "async": "~2.6.3" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1947,6 +2597,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2012,11 +2663,40 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/varuint-bitcoin": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", + "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2130,6 +2810,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2320,6 +3001,15 @@ "engines": { "node": "^20.19.0 || ^22.12.0 || >=23" } + }, + "node_modules/zone-file": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/zone-file/-/zone-file-2.0.0-beta.3.tgz", + "integrity": "sha512-6tE3PSRcpN5lbTTLlkLez40WkNPc9vw/u1J2j6DBiy0jcVX48nCkWrx2EC+bWHqC2SLp069Xw4AdnYn/qp/W5g==", + "license": "ISC", + "engines": { + "node": ">=10" + } } } } diff --git a/package.json b/package.json index 13cb76b..f3a7a26 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,21 @@ "private": true, "scripts": { "deploy:testnet": "node scripts/deploy.js", + "init:stackflow": "node scripts/init-stackflow.js", + "build:ui": "node scripts/build-ui.js", "test": "vitest run", + "test:watchtower:http": "WATCHTOWER_HTTP_INTEGRATION=1 vitest run tests/watchtower-http.integration.test.ts", "test:report": "vitest run -- --coverage --costs", - "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"" + "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"", + "build:watchtower": "tsc -p tsconfig.server.json", + "watchtower": "npm run build:watchtower && node server/dist/index.js" }, "author": "", "license": "ISC", "dependencies": { - "@stacks/network": "^7.2.0", "@stacks/clarinet-sdk": "^3.10.0", + "@stacks/connect": "^7.2.0", + "@stacks/network": "^7.2.0", "@stacks/transactions": "^7.2.0", "chokidar-cli": "^3.0.0", "dotenv": "^16.4.7", @@ -22,5 +28,9 @@ "vite": "^6.2.6", "vitest": "^3.1.1", "vitest-environment-clarinet": "^3.0.2" + }, + "devDependencies": { + "@types/node": "^25.2.3", + "esbuild": "^0.25.12" } } diff --git a/settings/Devnet.toml b/settings/Devnet.toml index 54a2c35..d2b166c 100644 --- a/settings/Devnet.toml +++ b/settings/Devnet.toml @@ -79,7 +79,7 @@ disable_stacks_api = false # disable_subnet_api = false # disable_bitcoin_explorer = true # working_dir = "tmp/devnet" -# stacks_node_events_observers = ["host.docker.internal:8002"] +stacks_node_events_observers = ["host.docker.internal:8787"] # miner_mnemonic = "fragile loan twenty basic net assault jazz absorb diet talk art shock innocent float punch travel gadget embrace caught blossom hockey surround initial reduce" # miner_derivation_path = "m/44'/5757'/0'/0/0" # faucet_mnemonic = "shadow private easily thought say logic fault paddle word top book during ignore notable orange flight clock image wealth health outside kitten belt reform" @@ -106,7 +106,7 @@ disable_stacks_api = false # stacks_node_image_url = "quay.io/hirosystems/stacks-node:devnet-3.0" # stacks_signer_image_url = "quay.io/hirosystems/stacks-signer:devnet-3.0" # stacks_api_image_url = "hirosystems/stacks-blockchain-api:master" -# stacks_explorer_image_url = "hirosystems/explorer:latest" +stacks_explorer_image_url = "ghcr.io/stx-labs/explorer:latest" # bitcoin_explorer_image_url = "quay.io/hirosystems/bitcoin-explorer:devnet" # postgres_image_url = "postgres:alpine" # enable_subnet_node = true diff --git a/tests/stackflow.test.ts b/tests/stackflow.test.ts index 2cb6c2f..ed5780c 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -6817,3 +6817,102 @@ describe("verify-signature-with-secret", () => { expect(result).toBeErr(Cl.uint(StackflowError.InvalidSignature)); }); }); + +describe("verify-signature-request", () => { + var pipeKey: ClarityValue; + + beforeEach(() => { + simnet.callPublicFn("stackflow", "init", [Cl.none()], deployer); + + simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(1000000), Cl.principal(address2), Cl.uint(0)], + address1 + ); + const { result } = simnet.callPublicFn( + "stackflow", + "fund-pipe", + [Cl.none(), Cl.uint(2000000), Cl.principal(address1), Cl.uint(0)], + address2 + ); + expect(result).toBeOk( + Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(address1), + "principal-2": Cl.principal(address2), + }) + ); + pipeKey = (result as ResponseOkCV).value; + + simnet.mineEmptyBlocks(CONFIRMATION_DEPTH); + }); + + it("validates withdrawal request signatures with action-aware amount", () => { + const signature = generateWithdrawSignature( + address2PK, + null, + address2, + address1, + 1500000, + 1000000, + 1, + address2 + ); + + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature-request", + [ + Cl.buffer(signature), + Cl.principal(address2), + pipeKey, + Cl.uint(1000000), + Cl.uint(1500000), + Cl.uint(1), + Cl.uint(PipeAction.Withdraw), + Cl.principal(address2), + Cl.none(), + Cl.none(), + Cl.uint(500000), + ], + address2 + ); + + expect(result).toBeOk(Cl.none()); + }); + + it("fails withdrawal request validation when amount does not match balances", () => { + const signature = generateWithdrawSignature( + address2PK, + null, + address2, + address1, + 1500000, + 1000000, + 1, + address2 + ); + + const { result } = simnet.callReadOnlyFn( + "stackflow", + "verify-signature-request", + [ + Cl.buffer(signature), + Cl.principal(address2), + pipeKey, + Cl.uint(1000000), + Cl.uint(1500000), + Cl.uint(1), + Cl.uint(PipeAction.Withdraw), + Cl.principal(address2), + Cl.none(), + Cl.none(), + Cl.uint(400000), + ], + address2 + ); + + expect(result).toBeErr(Cl.uint(StackflowError.InvalidTotalBalance)); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 1bdaf36..07f9f79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,9 @@ "noImplicitAny": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + "types": ["node"] }, "include": [ "node_modules/@hirosystems/clarinet-sdk/vitest-helpers/src", From 70372e2137143887d68826a8486481d9f3ce0489 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 18 Feb 2026 09:25:51 -0500 Subject: [PATCH 29/78] feat: add watchtower server --- deployments/default.devnet-plan.yaml | 49 + init-stackflow.sh | 5 + run-with-devnet.sh | 13 + scripts/build-ui.js | 25 + scripts/init-stackflow.js | 110 ++ server/DESIGN.md | 175 ++ server/data/watchtower-state.db | Bin 0 -> 65536 bytes server/src/config.ts | 167 ++ server/src/dispute-executor.ts | 141 ++ server/src/index.ts | 1024 +++++++++++ server/src/observer-parser.ts | 399 +++++ server/src/principal-utils.ts | 137 ++ server/src/producer-service.ts | 831 +++++++++ server/src/signature-verifier.ts | 197 +++ server/src/state-store.ts | 1009 +++++++++++ server/src/types.ts | 214 +++ server/src/watchtower.ts | 804 +++++++++ server/tsconfig.json | 5 + server/ui/index.html | 161 ++ server/ui/main.js | 1603 +++++++++++++++++ server/ui/main.src.js | 1888 +++++++++++++++++++++ server/ui/styles.css | 248 +++ tests/producer-service.test.ts | 330 ++++ tests/watchtower-dispute.test.ts | 358 ++++ tests/watchtower-http.integration.test.ts | 556 ++++++ tests/watchtower-observer.test.ts | 237 +++ tests/watchtower-state.test.ts | 279 +++ tsconfig.server.json | 25 + 28 files changed, 10990 insertions(+) create mode 100644 deployments/default.devnet-plan.yaml create mode 100755 init-stackflow.sh create mode 100755 run-with-devnet.sh create mode 100644 scripts/build-ui.js create mode 100644 scripts/init-stackflow.js create mode 100644 server/DESIGN.md create mode 100644 server/data/watchtower-state.db create mode 100644 server/src/config.ts create mode 100644 server/src/dispute-executor.ts create mode 100644 server/src/index.ts create mode 100644 server/src/observer-parser.ts create mode 100644 server/src/principal-utils.ts create mode 100644 server/src/producer-service.ts create mode 100644 server/src/signature-verifier.ts create mode 100644 server/src/state-store.ts create mode 100644 server/src/types.ts create mode 100644 server/src/watchtower.ts create mode 100644 server/tsconfig.json create mode 100644 server/ui/index.html create mode 100644 server/ui/main.js create mode 100644 server/ui/main.src.js create mode 100644 server/ui/styles.css create mode 100644 tests/producer-service.test.ts create mode 100644 tests/watchtower-dispute.test.ts create mode 100644 tests/watchtower-http.integration.test.ts create mode 100644 tests/watchtower-observer.test.ts create mode 100644 tests/watchtower-state.test.ts create mode 100644 tsconfig.server.json diff --git a/deployments/default.devnet-plan.yaml b/deployments/default.devnet-plan.yaml new file mode 100644 index 0000000..e9168ea --- /dev/null +++ b/deployments/default.devnet-plan.yaml @@ -0,0 +1,49 @@ +id: 0 +name: Devnet deployment +network: devnet +stacks-node: http://localhost:20443 +bitcoin-node: http://devnet:devnet@localhost:18443 +plan: + batches: + - id: 0 + transactions: + - transaction-type: requirement-publish + contract-id: SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard + remap-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + remap-principals: + SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 8400 + path: ./.cache/requirements/SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.clar + clarity-version: 1 + epoch: '2.0' + - id: 1 + transactions: + - transaction-type: contract-publish + contract-name: stackflow-token + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 43920 + path: contracts/stackflow-token.clar + anchor-block-only: true + clarity-version: 4 + - transaction-type: contract-publish + contract-name: reservoir + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 187420 + path: contracts/reservoir.clar + anchor-block-only: true + clarity-version: 4 + - transaction-type: contract-publish + contract-name: stackflow + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 491980 + path: contracts/stackflow.clar + anchor-block-only: true + clarity-version: 4 + - transaction-type: contract-publish + contract-name: test-token + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 23490 + path: contracts/test-token.clar + anchor-block-only: true + clarity-version: 4 + epoch: '3.3' diff --git a/init-stackflow.sh b/init-stackflow.sh new file mode 100755 index 0000000..823c069 --- /dev/null +++ b/init-stackflow.sh @@ -0,0 +1,5 @@ +STACKS_NETWORK=devnet \ +STACKS_API_URL=http://localhost:3999 \ +DEPLOYER_PRIVATE_KEY=753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601 \ +STACKFLOW_CONTRACT_ID=ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow \ +npm run init:stackflow \ No newline at end of file diff --git a/run-with-devnet.sh b/run-with-devnet.sh new file mode 100755 index 0000000..dda5f5f --- /dev/null +++ b/run-with-devnet.sh @@ -0,0 +1,13 @@ +WATCHTOWER_HOST=0.0.0.0 \ +WATCHTOWER_PORT=8787 \ +STACKS_NETWORK=devnet \ +STACKS_API_URL=http://localhost:3999 \ +STACKFLOW_CONTRACTS=ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow \ +WATCHTOWER_PRINCIPALS=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ +WATCHTOWER_SIGNATURE_VERIFIER_MODE=readonly \ +WATCHTOWER_DISPUTE_EXECUTOR_MODE=auto \ +WATCHTOWER_SIGNER_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ +WATCHTOWER_PRODUCER_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ +WATCHTOWER_PRODUCER_PRINCIPAL=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ +WATCHTOWER_LOG_RAW_EVENTS=true \ +npm run watchtower diff --git a/scripts/build-ui.js b/scripts/build-ui.js new file mode 100644 index 0000000..3294191 --- /dev/null +++ b/scripts/build-ui.js @@ -0,0 +1,25 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { build } from "esbuild"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const root = path.resolve(__dirname, ".."); + +const entry = path.resolve(root, "server/ui/main.src.js"); +const outfile = path.resolve(root, "server/ui/main.js"); + +await build({ + entryPoints: [entry], + outfile, + bundle: true, + format: "esm", + platform: "browser", + target: ["es2020"], + sourcemap: false, + minify: false, + legalComments: "none", +}); + +console.log(`[build-ui] bundled ${path.relative(root, entry)} -> ${path.relative(root, outfile)}`); diff --git a/scripts/init-stackflow.js b/scripts/init-stackflow.js new file mode 100644 index 0000000..d95a792 --- /dev/null +++ b/scripts/init-stackflow.js @@ -0,0 +1,110 @@ +import "dotenv/config"; + +import { createNetwork } from "@stacks/network"; +import { + AnchorMode, + PostConditionMode, + broadcastTransaction, + fetchNonce, + getAddressFromPrivateKey, + makeContractCall, + noneCV, +} from "@stacks/transactions"; + +const DEFAULT_DEVNET_DEPLOYER_KEY = + "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601"; + +function normalizePrivateKey(input) { + const trimmed = input.trim(); + return trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed; +} + +function normalizeNetwork(input) { + const value = String(input || "devnet").trim().toLowerCase(); + if (value === "mainnet" || value === "testnet" || value === "devnet" || value === "mocknet") { + return value; + } + throw new Error("STACKS_NETWORK must be one of: mainnet, testnet, devnet, mocknet"); +} + +function parseContractId(contractId) { + const normalized = contractId.startsWith("'") + ? contractId.slice(1) + : contractId; + const parts = normalized.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid STACKFLOW_CONTRACT_ID: ${contractId}`); + } + return { contractAddress: parts[0], contractName: parts[1] }; +} + +async function main() { + const stacksNetwork = normalizeNetwork(process.env.STACKS_NETWORK); + const stacksApiUrl = + process.env.STACKS_API_URL?.trim() || + (stacksNetwork === "mainnet" + ? "https://api.hiro.so" + : stacksNetwork === "testnet" + ? "https://api.testnet.hiro.so" + : "http://localhost:20443"); + + const deployerKeyInput = + process.env.DEPLOYER_PRIVATE_KEY?.trim() || DEFAULT_DEVNET_DEPLOYER_KEY; + if (!process.env.DEPLOYER_PRIVATE_KEY?.trim()) { + console.warn( + "[init-stackflow] DEPLOYER_PRIVATE_KEY not set; using default Clarinet devnet deployer key", + ); + } + + const senderKey = normalizePrivateKey(deployerKeyInput); + + const network = createNetwork({ + network: stacksNetwork, + client: { baseUrl: stacksApiUrl }, + }); + + const deployerAddress = getAddressFromPrivateKey(senderKey, network); + const contractId = + process.env.STACKFLOW_CONTRACT_ID?.trim() || `${deployerAddress}.stackflow`; + const { contractAddress, contractName } = parseContractId(contractId); + + console.log(`[init-stackflow] network=${stacksNetwork} api=${stacksApiUrl}`); + console.log(`[init-stackflow] deployer=${deployerAddress}`); + console.log(`[init-stackflow] contract=${contractAddress}.${contractName}`); + + const nonce = await fetchNonce({ + address: deployerAddress, + network: stacksNetwork, + client: { baseUrl: stacksApiUrl }, + }); + + const transaction = await makeContractCall({ + network, + senderKey, + contractAddress, + contractName, + functionName: "init", + functionArgs: [noneCV()], + nonce, + anchorMode: AnchorMode.Any, + postConditionMode: PostConditionMode.Allow, + validateWithAbi: false, + }); + + const result = await broadcastTransaction({ + transaction, + network, + }); + + if ("reason" in result) { + console.error("[init-stackflow] broadcast failed:", result); + process.exit(1); + } + + console.log("[init-stackflow] broadcast ok:", result.txid); +} + +main().catch((error) => { + console.error("[init-stackflow] fatal:", error); + process.exit(1); +}); diff --git a/server/DESIGN.md b/server/DESIGN.md new file mode 100644 index 0000000..03bd138 --- /dev/null +++ b/server/DESIGN.md @@ -0,0 +1,175 @@ +# Stackflow Watchtower Server Design + +## Purpose + +The watchtower server protects users from stale channel closures by: + +1. Accepting and persisting the latest valid signed state for watched users. +2. Listening to Stackflow `print` events from a Stacks node observer (`POST /new_block`). +3. Submitting `dispute-closure-for` when a fresher state is available. + +## Architecture + +- `src/index.ts` + - HTTP API, built-in UI static file serving, and dependency wiring. +- `src/watchtower.ts` + - Core decision engine and state transitions. +- `src/observer-parser.ts` + - Normalizes observer payloads into Stackflow events. +- `src/signature-verifier.ts` + - Read-only on-chain signature validation. +- `src/dispute-executor.ts` + - Broadcasts disputes on-chain. +- `src/state-store.ts` + - SQLite persistence layer. + +## Persistence (SQLite) + +State is persisted in SQLite: + +- Default: `server/data/watchtower-state.db` +- Config: `WATCHTOWER_DB_FILE` +- Backward-compatible alias: `WATCHTOWER_STATE_FILE` + +### SQLite settings + +On startup the store configures: + +- `PRAGMA journal_mode = WAL` +- `PRAGMA synchronous = NORMAL` +- `PRAGMA foreign_keys = ON` + +### Schema + +- `meta(key PRIMARY KEY, value)` + - `version` + - `updated_at` +- `closures(pipe_id PRIMARY KEY, ...)` +- `signature_states(state_id PRIMARY KEY, ...)` + - Index: `(contract_id, pipe_id)` +- `dispute_attempts(attempt_id PRIMARY KEY, ...)` + - Index: `created_at DESC` +- `recent_events(seq INTEGER PRIMARY KEY AUTOINCREMENT, event_json, observed_at)` + +### Logical keys + +- `pipeId = "||"` +- `stateId = "||"` +- `attemptId = "|"` + +### Data lifecycle + +- Every write updates `meta.updated_at`. +- `recent_events` is capped by `WATCHTOWER_MAX_RECENT_EVENTS` (default `500`). +- `recent_events` is pruned after each insert. + +## API + +### Write endpoints + +- `POST /new_block` + - Observer payload ingestion. +- `POST /new_burn_block` +- `POST /new_mempool_tx` +- `POST /drop_mempool_tx` +- `POST /new_microblocks` + - Stacks-node observer compatibility endpoints. They are accepted and ignored. +- `POST /signature-states` + - Off-chain state submission. +- `POST /producer/transfer` + - Producer-mode transfer signing (`action=1`). +- `POST /producer/signature-request` + - Producer-mode close/deposit/withdraw signing (`action=0|2|3`). + - For `action=2|3`, request payload must include `amount`. + +Producer-mode endpoints apply local policy before signing: + +- Reject if requested nonce is not strictly higher than latest known nonce. +- Reject if producer balance would decrease. +- For transfer requests (`action=1`), require producer balance to strictly increase + and preserve total channel balance. +- Counterparty signatures are validated via on-chain read-only + `verify-signature-request`, including action-aware amount checks. + +### Read endpoints + +- `GET /health` +- `GET /closures` +- `GET /signature-states?limit=100` +- `GET /dispute-attempts?limit=100` +- `GET /events?limit=100` +- `GET /app` + - Browser UI for wallet connect, watched-pipe view, signature generation, and contract actions. + +### Status semantics + +`POST /signature-states`: + +- `200`: accepted +- `401`: signature validation failed +- `403`: `forPrincipal` not in watch allowlist +- `400`: malformed input + +## Ingestion and Decision Flow + +1. Receive observer payload on `POST /new_block`. +2. Parse only Stackflow `print` events. +3. Apply contract filter: + - explicit `STACKFLOW_CONTRACTS`, or + - default `*.stackflow*` matcher. +4. Apply principal scope filter (`WATCHTOWER_PRINCIPALS`) if configured. +5. Record event in `recent_events`. +6. Update closure state: + - open: `force-close`, `force-cancel` + - terminal: `close-pipe`, `dispute-closure`, `finalize` +7. On open closure, attempt dispute if enabled and eligible. + +## Signature State Acceptance + +For `POST /signature-states`: + +1. Parse and validate all fields (principal/uint/hex sizes). +2. Enforce watched principal allowlist on `forPrincipal`. +3. Validate signatures via contract read-only `verify-signatures`. +4. Canonicalize pipe key principal ordering. +5. Upsert only if incoming nonce is not lower than existing nonce. + +## Dispute Eligibility + +Candidate state must satisfy: + +- Same `(contractId, pipeId)`. +- `state.forPrincipal !== closer`. +- `state.nonce > closure.nonce`. +- `state.validAfter <= blockHeight` if `validAfter` exists. +- If beneficial policy is active: + - `state.myBalance > closure-side-balance`. + +Deduping: + +- A successful `attemptId` is not re-submitted. + +## Config + +- `WATCHTOWER_HOST`, `WATCHTOWER_PORT` +- `WATCHTOWER_DB_FILE` (or alias `WATCHTOWER_STATE_FILE`) +- `WATCHTOWER_MAX_RECENT_EVENTS` +- `STACKFLOW_CONTRACTS` +- `WATCHTOWER_PRINCIPALS` (CSV allowlist, max 100) +- `STACKS_NETWORK` +- `STACKS_API_URL` +- `WATCHTOWER_SIGNER_KEY` +- `WATCHTOWER_PRODUCER_KEY` +- `WATCHTOWER_PRODUCER_PRINCIPAL` +- `WATCHTOWER_PRODUCER_SIGNER_MODE` (`local-key|kms`) +- `WATCHTOWER_STACKFLOW_MESSAGE_VERSION` +- `WATCHTOWER_SIGNATURE_VERIFIER_MODE` (`readonly|accept-all|reject-all`) +- `WATCHTOWER_DISPUTE_EXECUTOR_MODE` (`auto|noop|mock`) +- `WATCHTOWER_DISPUTE_ONLY_BENEFICIAL` + +## Production Notes + +- SQLite provides transactional durability and better recovery than JSON file snapshots. +- WAL mode improves write reliability and read concurrency for status endpoints. +- This implementation uses `node:sqlite`; on current Node versions it may emit an experimental warning. +- For HA/multi-instance operation, run a single active dispute writer or add leader coordination. diff --git a/server/data/watchtower-state.db b/server/data/watchtower-state.db new file mode 100644 index 0000000000000000000000000000000000000000..fced8eca8ce47f0fcefa138ab685e7f68c49c45e GIT binary patch literal 65536 zcmeI*%WvDr9S3m9FU8I}$^vzC2!d4v6p5QC%OQs^r)lcgisCp?{Lp4?7X`t`(B@jE zv=ZewNOF*oE!v{jUi+8y)N_H}dg(pKUWx)ec1XPy+fj^U;WfU5EQ_4saOT6`j7*BA z)>of+0ZY5(>Yf>-rP$@z$Vlwh>2xd>8{t2f`A>VA#)qFI<`WbD}o>w}d$Ui!qNUV}bw#&W6BBaw2u>R(vxs ztrDw!U*cngQDfiNnQsryUS3$s%@=d&;{1arx%5EZo4&cnZu2!~8^5}gNZq&*KfD{2 zS!Fi!1B31J=UU%Pvah_pr1Z_HbZg>8rk4uETt2s!URhguI={A=Ue0Z%=f5dFTPpA( zp5_Y0+nwyuc8u?8Wj|faZ4}dmXZ+`zCr`RL%2th4_nBkxVX!YttBl#k%yc5PoQ*f` zd%nXCJmq?N!WDzkGKyLDk#dW>q8L>`|U>Qmt6tu$KtOw<2{{4_3?ajA?WV(@_N~BhKAU*2@Qq9};&7fZ8oh;x1 z!~?1A8tM(EUVJZ>26ObU-y2SYId68hlLRpO!l|DJ=ETxJgJLyJldUw3@({!Oe7MiyR-3z-05PS@jdEMM)=&n z=)!xwsy{tirMD~fCm++1emR~<&CbSuFGU@xf0h2ZiM~$MpY~&Sp{Nt?vX>{mW43yM zL86`5jgall!b%>WF2tWV=5P$##AOHafK;R4tyv@b0yq%9qWA%`e=3exv=(lW9#82Sp`mLsHZmcfk9_BX0t<{yhrWO{%jCo=C z`I<)cr(2I6E1M7K;zB`NmWykP&o>_G;=H2Jl}8)J&r|9t+V?8cxyf*rWacu%HZ^m* zXGO8O;&4spYX;%%V>5Gpy;OQ55TP|mR1h^$QWRYjbV;L%#2=DM6+xvEQ58)jgvzqu z)_o@%-ak<|kVRr@f~pZ$u@&l4>Z*z>>BMmbNpeJ{YA&H+S&pc?wk*nmsW5?QRJN&0 z7?Y)en*k_uM7={rx)px@A5T_d{DBDq5P$##AOHafKmY;|fB*y_0D(^=(6}^qczyB| zCon$K2^t*#f1(|WVjutk2tWV=5P$##AOHafKmY)nv!fR7Y}L%EH%FcLY@y zG*NIYN4JT~1cxbMH4a=?F%`kqOiiQAA*w4nL?fE+NHVXDMHEL7T}vg*Rw>aOeo;QL zTz)0Ksc4d7%927%%d}jTsS2+bb3|2l`9ewL<=djI@@w{0NmXrKGx@f83v|`e1x*PH z7x=#e2t*_wA$atc0X9GX&&QJabJw-eAP7JJ0uX=z1Rwwb2tWV=5P-m^7HFtrhqcpu zs{ffT>mT}@5w8FL)S-a#AOHafKmY;|fB*y_009U<00ObtS-k!q-~Wfmga8B}009U< z00Izz00bZa0SKILf$;Nx9RHv1eT>#Y00Izz00bZa0SG_<0uX=z1aSP17=Qo-AOHaf zKmY;|fB*y_009V`e}U-!|6ja|@dqXdKmY;|fB*y_009U<00QSg;CHF`c-I!gZI~%go%X2*~y-W+_XWg%olR zG&;YYD=cm-Z{{DbtbVI0n;WYOxrez8acgxYuc?K_unaESTv7bUtq0{@=4a;oda3lL zS(mUPmRT}=n?=dwQBo9L6vCaZNc item.trim()) + .filter(Boolean); +} + +function parsePrincipalCsv(value: unknown): string[] { + const principals = parseCsv(value).map((principal) => + parsePrincipal(principal, 'WATCHTOWER_PRINCIPALS'), + ); + + if (principals.length > MAX_WATCHED_PRINCIPALS) { + throw new Error( + `WATCHTOWER_PRINCIPALS exceeds max of ${MAX_WATCHED_PRINCIPALS} entries`, + ); + } + + return [...new Set(principals)]; +} + +function parseBoolean(value: unknown, fallback: boolean): boolean { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + + return fallback; +} + +function parseNetwork(value: unknown): 'mainnet' | 'testnet' | 'devnet' | 'mocknet' { + const normalized = String(value || 'devnet').trim().toLowerCase(); + if (normalized === 'mainnet' || normalized === 'testnet' || normalized === 'mocknet') { + return normalized; + } + return 'devnet'; +} + +function parseSignatureVerifierMode(value: unknown): SignatureVerifierMode { + const normalized = String(value || 'readonly').trim().toLowerCase(); + if ( + normalized === 'readonly' || + normalized === 'accept-all' || + normalized === 'reject-all' + ) { + return normalized; + } + + throw new Error( + 'WATCHTOWER_SIGNATURE_VERIFIER_MODE must be readonly, accept-all, or reject-all', + ); +} + +function parseDisputeExecutorMode(value: unknown): DisputeExecutorMode { + const normalized = String(value || 'auto').trim().toLowerCase(); + if (normalized === 'auto' || normalized === 'noop' || normalized === 'mock') { + return normalized; + } + + throw new Error( + 'WATCHTOWER_DISPUTE_EXECUTOR_MODE must be auto, noop, or mock', + ); +} + +function parseProducerSignerMode(value: unknown): ProducerSignerMode { + const normalized = String(value || 'local-key').trim().toLowerCase(); + if (normalized === 'local-key' || normalized === 'kms') { + return normalized; + } + + throw new Error( + 'WATCHTOWER_PRODUCER_SIGNER_MODE must be local-key or kms', + ); +} + +function parseStackflowMessageVersion(value: unknown): string { + const text = String(value || '0.6.0').trim(); + if (text.length === 0) { + throw new Error('WATCHTOWER_STACKFLOW_MESSAGE_VERSION must not be empty'); + } + if (!/^[\x20-\x7E]+$/.test(text)) { + throw new Error('WATCHTOWER_STACKFLOW_MESSAGE_VERSION must be ASCII'); + } + return text; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): WatchtowerConfig { + const dbFile = + env.WATCHTOWER_DB_FILE?.trim() || + env.WATCHTOWER_STATE_FILE?.trim() || + DEFAULT_DB_FILE; + + return { + host: env.WATCHTOWER_HOST?.trim() || DEFAULT_HOST, + port: parseInteger(env.WATCHTOWER_PORT, DEFAULT_PORT), + dbFile, + maxRecentEvents: parseInteger( + env.WATCHTOWER_MAX_RECENT_EVENTS, + DEFAULT_MAX_RECENT_EVENTS, + ), + logRawEvents: parseBoolean(env.WATCHTOWER_LOG_RAW_EVENTS, false), + watchedContracts: parseCsv(env.STACKFLOW_CONTRACTS), + watchedPrincipals: parsePrincipalCsv(env.WATCHTOWER_PRINCIPALS), + stacksNetwork: parseNetwork(env.STACKS_NETWORK), + stacksApiUrl: env.STACKS_API_URL?.trim() || null, + signerKey: env.WATCHTOWER_SIGNER_KEY?.trim() || null, + producerKey: + env.WATCHTOWER_PRODUCER_KEY?.trim() || env.WATCHTOWER_SIGNER_KEY?.trim() || null, + producerPrincipal: env.WATCHTOWER_PRODUCER_PRINCIPAL?.trim() || null, + producerSignerMode: parseProducerSignerMode( + env.WATCHTOWER_PRODUCER_SIGNER_MODE, + ), + stackflowMessageVersion: parseStackflowMessageVersion( + env.WATCHTOWER_STACKFLOW_MESSAGE_VERSION, + ), + signatureVerifierMode: parseSignatureVerifierMode( + env.WATCHTOWER_SIGNATURE_VERIFIER_MODE, + ), + disputeExecutorMode: parseDisputeExecutorMode( + env.WATCHTOWER_DISPUTE_EXECUTOR_MODE, + ), + disputeOnlyBeneficial: parseBoolean( + env.WATCHTOWER_DISPUTE_ONLY_BENEFICIAL, + false, + ), + }; +} diff --git a/server/src/dispute-executor.ts b/server/src/dispute-executor.ts new file mode 100644 index 0000000..4a82436 --- /dev/null +++ b/server/src/dispute-executor.ts @@ -0,0 +1,141 @@ +import { createNetwork } from '@stacks/network'; +import { + PostConditionMode, + broadcastTransaction, + bufferCV, + getAddressFromPrivateKey, + makeContractCall, + noneCV, + principalCV, + someCV, + uintCV, +} from '@stacks/transactions'; + +import { hexToBytes, splitContractId } from './principal-utils.js'; +import type { + ClosureRecord, + DisputeExecutor, + SignatureStateRecord, + StackflowPrintEvent, + SubmitDisputeResult, + WatchtowerConfig, +} from './types.js'; + +function normalizePrivateKey(input: string): string { + const trimmed = input.trim(); + return trimmed.startsWith('0x') ? trimmed.slice(2) : trimmed; +} + +function parseContractPrincipal(contractId: string): { address: string; name: string } { + return splitContractId(contractId); +} + +export class StacksDisputeExecutor implements DisputeExecutor { + readonly enabled: boolean; + + readonly signerAddress: string | null; + + private readonly network: ReturnType; + + private readonly signerKey: string | null; + + constructor(config: Pick) { + this.network = createNetwork({ + network: config.stacksNetwork, + client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, + }); + + this.signerKey = config.signerKey ? normalizePrivateKey(config.signerKey) : null; + + this.enabled = Boolean(this.signerKey); + this.signerAddress = this.signerKey + ? getAddressFromPrivateKey(this.signerKey, this.network) + : null; + } + + async submitDispute({ + signatureState, + }: { + signatureState: SignatureStateRecord; + closure: ClosureRecord; + triggerEvent: StackflowPrintEvent; + }): Promise { + if (!this.signerKey) { + throw new Error('watchtower signer key not configured'); + } + + const contract = parseContractPrincipal(signatureState.contractId); + + const tokenArg = signatureState.token + ? someCV(principalCV(signatureState.token)) + : noneCV(); + + const secretArg = signatureState.secret + ? someCV(bufferCV(hexToBytes(signatureState.secret))) + : noneCV(); + + const validAfterArg = signatureState.validAfter + ? someCV(uintCV(BigInt(signatureState.validAfter))) + : noneCV(); + + const tx = await makeContractCall({ + network: this.network, + senderKey: this.signerKey, + contractAddress: contract.address, + contractName: contract.name, + functionName: 'dispute-closure-for', + functionArgs: [ + principalCV(signatureState.forPrincipal), + tokenArg, + principalCV(signatureState.withPrincipal), + uintCV(BigInt(signatureState.myBalance)), + uintCV(BigInt(signatureState.theirBalance)), + bufferCV(hexToBytes(signatureState.mySignature)), + bufferCV(hexToBytes(signatureState.theirSignature)), + uintCV(BigInt(signatureState.nonce)), + uintCV(BigInt(signatureState.action)), + principalCV(signatureState.actor), + secretArg, + validAfterArg, + ], + postConditionMode: PostConditionMode.Allow, + validateWithAbi: false, + }); + + const result = await broadcastTransaction({ + transaction: tx, + network: this.network, + }); + + if ('reason' in result) { + throw new Error( + `dispute broadcast failed: ${result.reason}${result.error ? ` (${result.error})` : ''}`, + ); + } + + return { txid: result.txid }; + } +} + +export class NoopDisputeExecutor implements DisputeExecutor { + readonly enabled = false; + + readonly signerAddress = null; + + async submitDispute(): Promise { + throw new Error('dispute executor disabled'); + } +} + +export class MockDisputeExecutor implements DisputeExecutor { + readonly enabled = true; + + readonly signerAddress = 'ST3AM1A8YQ4X5MMR7Z5T3VYV9N0ZVEX7QPHQ4RM9P'; + + private nonce = 0; + + async submitDispute(): Promise { + this.nonce += 1; + return { txid: `0xmock${this.nonce.toString(16).padStart(8, '0')}` }; + } +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..7beaf28 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,1024 @@ +import 'dotenv/config'; + +import { readFile } from 'node:fs/promises'; +import http from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import path from 'node:path'; + +import { loadConfig } from './config.js'; +import { + MockDisputeExecutor, + NoopDisputeExecutor, + StacksDisputeExecutor, +} from './dispute-executor.js'; +import { + AcceptAllSignatureVerifier, + ReadOnlySignatureVerifier, + RejectAllSignatureVerifier, +} from './signature-verifier.js'; +import { + createProducerSigner, + ProducerService, + ProducerServiceError, +} from './producer-service.js'; +import { SqliteStateStore } from './state-store.js'; +import { canonicalPipeKey } from './principal-utils.js'; +import type { + DisputeExecutor, + PipeKey, + SignatureVerifier, + WatchtowerStatus, +} from './types.js'; +import { + PrincipalNotWatchedError, + SignatureValidationError, + Watchtower, +} from './watchtower.js'; + +const MAX_BODY_BYTES = 5 * 1024 * 1024; +const UI_ROOT = path.resolve(process.cwd(), 'server/ui'); +const STACKS_NODE_COMPAT_ROUTES = new Set([ + '/new_mempool_tx', + '/drop_mempool_tx', + '/new_microblocks', +]); +const DEFAULT_STACKFLOW_CONTRACT_PATTERN = /\.stackflow(?:[-.].+)?$/i; +const RAW_EVENT_LOG_MAX_CHARS = 25_000; + +const UI_FILE_MAP: Record = { + '/app': { + file: 'index.html', + contentType: 'text/html; charset=utf-8', + }, + '/app/': { + file: 'index.html', + contentType: 'text/html; charset=utf-8', + }, + '/app/index.html': { + file: 'index.html', + contentType: 'text/html; charset=utf-8', + }, + '/app/main.js': { + file: 'main.js', + contentType: 'application/javascript; charset=utf-8', + }, + '/app/styles.css': { + file: 'styles.css', + contentType: 'text/css; charset=utf-8', + }, +}; + +function writeJson( + response: ServerResponse, + statusCode: number, + payload: Record, +): void { + response.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify(payload)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function summarizeNewBlockPayload(payload: unknown): string { + if (!isRecord(payload)) { + return 'payload=non-object'; + } + + const blockHeight = + typeof payload.block_height === 'number' || typeof payload.block_height === 'string' + ? String(payload.block_height) + : typeof payload.blockHeight === 'number' || typeof payload.blockHeight === 'string' + ? String(payload.blockHeight) + : '?'; + + const eventCount = Array.isArray(payload.events) ? payload.events.length : 0; + const txCount = Array.isArray(payload.transactions) ? payload.transactions.length : 0; + + return `block=${blockHeight} events=${eventCount} txs=${txCount}`; +} + +function parseUintLike(value: unknown): string | null { + if (typeof value === 'bigint' && value >= 0n) { + return value.toString(10); + } + + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + return String(Math.trunc(value)); + } + + if (typeof value === 'string' && /^\d+$/.test(value.trim())) { + return value.trim(); + } + + return null; +} + +function extractBurnBlockHeight(payload: unknown): string | null { + const queue: unknown[] = [payload]; + const visited = new Set(); + const keys = ['burn_block_height', 'burnBlockHeight', 'block_height', 'blockHeight']; + + while (queue.length > 0) { + const current = queue.shift(); + + if (Array.isArray(current)) { + for (const item of current) { + queue.push(item); + } + continue; + } + + if (!isRecord(current)) { + continue; + } + + if (visited.has(current)) { + continue; + } + visited.add(current); + + for (const key of keys) { + const value = parseUintLike(current[key]); + if (value !== null) { + return value; + } + } + + for (const value of Object.values(current)) { + queue.push(value); + } + } + + return null; +} + +function stringifyForLog(value: unknown): string { + try { + const encoded = JSON.stringify(value); + if (encoded.length <= RAW_EVENT_LOG_MAX_CHARS) { + return encoded; + } + return `${encoded.slice(0, RAW_EVENT_LOG_MAX_CHARS)}...[truncated]`; + } catch { + return '[unserializable-event]'; + } +} + +function contractMatches(contractId: string, watchedContracts: string[]): boolean { + if (watchedContracts.length > 0) { + return watchedContracts.includes(contractId); + } + return DEFAULT_STACKFLOW_CONTRACT_PATTERN.test(contractId); +} + +function extractRawStackflowPrintEventSamples( + payload: unknown, + watchedContracts: string[], +): Record[] { + const queue: unknown[] = [payload]; + const visited = new Set(); + const samples: Record[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + + if (Array.isArray(current)) { + for (const item of current) { + queue.push(item); + } + continue; + } + + if (!isRecord(current)) { + continue; + } + + if (visited.has(current)) { + continue; + } + visited.add(current); + + const candidateEvents: Array<{ + envelope: Record; + event: Record; + }> = []; + + if (isRecord(current.contract_event)) { + candidateEvents.push({ envelope: current, event: current.contract_event }); + } + + if (isRecord(current.contract_log)) { + candidateEvents.push({ envelope: current, event: current.contract_log }); + } + + const hasValue = + current.raw_value !== undefined || + current.rawValue !== undefined || + current.value !== undefined; + const hasEventRef = + current.txid !== undefined || + current.tx_id !== undefined || + current.event_index !== undefined || + current.eventIndex !== undefined; + + if ( + typeof current.contract_identifier === 'string' && + typeof current.topic === 'string' && + hasValue && + hasEventRef + ) { + candidateEvents.push({ envelope: current, event: current }); + } + + for (const candidate of candidateEvents) { + const contractId = candidate.event.contract_identifier; + const topic = candidate.event.topic; + if ( + typeof contractId === 'string' && + topic === 'print' && + contractMatches(contractId, watchedContracts) + ) { + samples.push(candidate.envelope); + } + } + + for (const value of Object.values(current)) { + queue.push(value); + } + } + + return samples; +} + +function readJsonBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + + request.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('request body too large')); + request.destroy(); + return; + } + + chunks.push(chunk); + }); + + request.on('end', () => { + if (chunks.length === 0) { + resolve({}); + return; + } + + try { + const raw = Buffer.concat(chunks).toString('utf8'); + resolve(JSON.parse(raw)); + } catch { + reject(new Error('invalid json')); + } + }); + + request.on('error', reject); + }); +} + +function discardBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + request.on('data', () => { + // Intentionally ignore body content for compatibility endpoints. + }); + request.on('end', () => resolve()); + request.on('error', reject); + }); +} + +function parseLimit(url: URL): number { + const limit = url.searchParams.get('limit'); + if (!limit) { + return 100; + } + + const parsed = Number.parseInt(limit, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 100; + } + + return Math.min(parsed, 500); +} + +type MergedPipeRecord = { + stateId: string; + pipeId: string; + contractId: string; + pipeKey: PipeKey; + balance1: string | null; + balance2: string | null; + pending1Amount: string | null; + pending1BurnHeight: string | null; + pending2Amount: string | null; + pending2BurnHeight: string | null; + expiresAt: string | null; + nonce: string | null; + closer: string | null; + event: string; + txid: string | null; + blockHeight: string | null; + updatedAt: string; + source: 'onchain' | 'signature-state'; +}; + +function nonceValue(value: string | null): bigint { + if (!value) { + return -1n; + } + + try { + return BigInt(value); + } catch { + return -1n; + } +} + +function shouldReplacePipe(existing: MergedPipeRecord, incoming: MergedPipeRecord): boolean { + const existingNonce = nonceValue(existing.nonce); + const incomingNonce = nonceValue(incoming.nonce); + + if (incomingNonce !== existingNonce) { + return incomingNonce > existingNonce; + } + + if (incoming.updatedAt !== existing.updatedAt) { + return incoming.updatedAt > existing.updatedAt; + } + + if (existing.source !== incoming.source) { + return incoming.source === 'onchain'; + } + + return false; +} + +function mergeAuthoritativePipes( + status: WatchtowerStatus, + principal: string | null, +): MergedPipeRecord[] { + const records = new Map(); + + for (const observed of status.observedPipes) { + if ( + principal && + observed.pipeKey['principal-1'] !== principal && + observed.pipeKey['principal-2'] !== principal + ) { + continue; + } + + records.set(observed.stateId, { + ...observed, + source: 'onchain', + }); + } + + for (const signature of status.signatureStates) { + const pipeKey = canonicalPipeKey( + signature.token, + signature.forPrincipal, + signature.withPrincipal, + ); + + if ( + principal && + pipeKey['principal-1'] !== principal && + pipeKey['principal-2'] !== principal + ) { + continue; + } + + const stateId = `${signature.contractId}|${signature.pipeId}`; + const principal1IsSigner = pipeKey['principal-1'] === signature.forPrincipal; + + const candidate: MergedPipeRecord = { + stateId, + pipeId: signature.pipeId, + contractId: signature.contractId, + pipeKey, + balance1: principal1IsSigner ? signature.myBalance : signature.theirBalance, + balance2: principal1IsSigner ? signature.theirBalance : signature.myBalance, + pending1Amount: null, + pending1BurnHeight: null, + pending2Amount: null, + pending2BurnHeight: null, + expiresAt: null, + nonce: signature.nonce, + closer: null, + event: 'signature-state', + txid: null, + blockHeight: null, + updatedAt: signature.updatedAt, + source: 'signature-state', + }; + + const existing = records.get(stateId); + if (!existing || shouldReplacePipe(existing, candidate)) { + records.set(stateId, candidate); + } + } + + return [...records.values()].sort((left, right) => { + const leftNonce = nonceValue(left.nonce); + const rightNonce = nonceValue(right.nonce); + if (leftNonce !== rightNonce) { + return rightNonce > leftNonce ? 1 : -1; + } + + if (left.updatedAt !== right.updatedAt) { + return right.updatedAt.localeCompare(left.updatedAt); + } + + return left.stateId.localeCompare(right.stateId); + }); +} + +async function maybeServeUi( + pathname: string, + response: ServerResponse, +): Promise { + const asset = UI_FILE_MAP[pathname]; + if (!asset) { + return false; + } + + try { + const filePath = path.join(UI_ROOT, asset.file); + const content = await readFile(filePath); + response.writeHead(200, { + 'content-type': asset.contentType, + 'cache-control': 'no-store', + }); + response.end(content); + } catch { + writeJson(response, 500, { + ok: false, + error: 'failed to load ui asset', + }); + } + + return true; +} + +function createHandler({ + watchtower, + producerService, + startedAt, + disputeEnabled, + signerAddress, + producerEnabled, + producerPrincipal, + stacksNetwork, + watchedContracts, + logRawEvents, +}: { + watchtower: Watchtower; + producerService: ProducerService; + startedAt: string; + disputeEnabled: boolean; + signerAddress: string | null; + producerEnabled: boolean; + producerPrincipal: string | null; + stacksNetwork: 'mainnet' | 'testnet' | 'devnet' | 'mocknet'; + watchedContracts: string[]; + logRawEvents: boolean; +}) { + return async ( + request: IncomingMessage, + response: ServerResponse, + ): Promise => { + const method = request.method || 'GET'; + const url = new URL(request.url || '/', 'http://localhost'); + + if (method === 'GET') { + const served = await maybeServeUi(url.pathname, response); + if (served) { + return; + } + } + + if (method === 'GET' && url.pathname === '/health') { + const status = watchtower.status(); + + writeJson(response, 200, { + ok: true, + startedAt, + updatedAt: status.updatedAt, + activeClosures: status.activeClosures.length, + observedPipes: status.observedPipes.length, + signatureStates: status.signatureStates.length, + disputeEnabled, + signerAddress, + producerEnabled, + producerPrincipal, + stacksNetwork, + }); + return; + } + + if (method === 'GET' && url.pathname === '/closures') { + const status = watchtower.status(); + writeJson(response, 200, { + ok: true, + closures: status.activeClosures, + }); + return; + } + + if (method === 'GET' && url.pathname === '/signature-states') { + const status = watchtower.status(); + const limit = parseLimit(url); + + writeJson(response, 200, { + ok: true, + signatureStates: status.signatureStates.slice(0, limit), + }); + return; + } + + if (method === 'GET' && url.pathname === '/pipes') { + const status = watchtower.status(); + const limit = parseLimit(url); + const principal = url.searchParams.get('principal')?.trim() || null; + const pipes = mergeAuthoritativePipes(status, principal); + + writeJson(response, 200, { + ok: true, + pipes: pipes.slice(0, limit), + }); + return; + } + + if (method === 'GET' && url.pathname === '/dispute-attempts') { + const status = watchtower.status(); + const limit = parseLimit(url); + + writeJson(response, 200, { + ok: true, + disputeAttempts: status.disputeAttempts.slice(0, limit), + }); + return; + } + + if (method === 'GET' && url.pathname === '/events') { + const status = watchtower.status(); + const limit = parseLimit(url); + writeJson(response, 200, { + ok: true, + events: status.recentEvents.slice(0, limit), + }); + return; + } + + if (method === 'POST' && url.pathname === '/signature-states') { + try { + const payload = await readJsonBody(request); + const result = await watchtower.upsertSignatureState(payload); + + if (!result.stored && result.reason === 'nonce-too-low') { + const incomingNonce = + isRecord(payload) && + (typeof payload.nonce === 'string' || + typeof payload.nonce === 'number' || + typeof payload.nonce === 'bigint') + ? String(payload.nonce) + : null; + + console.warn( + `[watchtower] /signature-states rejected status=409 reason=nonce-too-low incomingNonce=${ + incomingNonce ?? '-' + } existingNonce=${result.state.nonce} stateId=${result.state.stateId}`, + ); + writeJson(response, 409, { + ok: false, + error: 'nonce-too-low', + reason: 'nonce-too-low', + incomingNonce, + existingNonce: result.state.nonce, + state: result.state, + }); + return; + } + + writeJson(response, 200, { + ok: true, + ...result, + }); + } catch (error) { + if (error instanceof SignatureValidationError) { + console.warn( + `[watchtower] /signature-states rejected status=401 error=${error.message}`, + ); + writeJson(response, 401, { + ok: false, + error: error.message, + }); + return; + } + + if (error instanceof PrincipalNotWatchedError) { + console.warn( + `[watchtower] /signature-states rejected status=403 error=${error.message}`, + ); + writeJson(response, 403, { + ok: false, + error: error.message, + }); + return; + } + + console.warn( + `[watchtower] /signature-states rejected status=400 error=${ + error instanceof Error ? error.message : 'failed to store signature state' + }`, + ); + writeJson(response, 400, { + ok: false, + error: + error instanceof Error + ? error.message + : 'failed to store signature state', + }); + } + return; + } + + if (method === 'POST' && url.pathname === '/producer/transfer') { + try { + const payload = await readJsonBody(request); + const result = await producerService.signTransfer(payload); + + if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { + console.warn( + `[watchtower] /producer/transfer rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, + ); + writeJson(response, 409, { + ok: false, + error: 'nonce-too-low', + reason: 'nonce-too-low', + incomingNonce: result.request.nonce, + existingNonce: result.upsert.state.nonce, + state: result.upsert.state, + }); + return; + } + + writeJson(response, 200, { + ok: true, + producerPrincipal: result.request.forPrincipal, + withPrincipal: result.request.withPrincipal, + token: result.request.token, + amount: result.request.amount, + nonce: result.request.nonce, + action: result.request.action, + actor: result.request.actor, + myBalance: result.request.myBalance, + theirBalance: result.request.theirBalance, + mySignature: result.mySignature, + theirSignature: result.request.theirSignature, + stored: result.upsert.stored, + replaced: result.upsert.replaced, + reason: result.upsert.reason, + }); + } catch (error) { + if (error instanceof ProducerServiceError) { + console.warn( + `[watchtower] /producer/transfer rejected status=${error.statusCode} error=${error.message}`, + ); + const details = + error.details && typeof error.details === 'object' + ? error.details + : {}; + writeJson(response, error.statusCode, { + ok: false, + error: error.message, + ...details, + }); + return; + } + + console.error( + `[watchtower] /producer/transfer error: ${ + error instanceof Error ? error.message : 'failed to sign transfer' + }`, + ); + writeJson(response, 500, { + ok: false, + error: + error instanceof Error ? error.message : 'failed to sign transfer', + }); + } + return; + } + + if (method === 'POST' && url.pathname === '/producer/signature-request') { + try { + const payload = await readJsonBody(request); + const result = await producerService.signSignatureRequest(payload); + + if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { + console.warn( + `[watchtower] /producer/signature-request rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, + ); + writeJson(response, 409, { + ok: false, + error: 'nonce-too-low', + reason: 'nonce-too-low', + incomingNonce: result.request.nonce, + existingNonce: result.upsert.state.nonce, + state: result.upsert.state, + }); + return; + } + + writeJson(response, 200, { + ok: true, + producerPrincipal: result.request.forPrincipal, + withPrincipal: result.request.withPrincipal, + token: result.request.token, + amount: result.request.amount, + nonce: result.request.nonce, + action: result.request.action, + actor: result.request.actor, + myBalance: result.request.myBalance, + theirBalance: result.request.theirBalance, + mySignature: result.mySignature, + theirSignature: result.request.theirSignature, + stored: result.upsert.stored, + replaced: result.upsert.replaced, + reason: result.upsert.reason, + }); + } catch (error) { + if (error instanceof ProducerServiceError) { + console.warn( + `[watchtower] /producer/signature-request rejected status=${error.statusCode} error=${error.message}`, + ); + const details = + error.details && typeof error.details === 'object' + ? error.details + : {}; + writeJson(response, error.statusCode, { + ok: false, + error: error.message, + ...details, + }); + return; + } + + console.error( + `[watchtower] /producer/signature-request error: ${ + error instanceof Error ? error.message : 'failed to sign request' + }`, + ); + writeJson(response, 500, { + ok: false, + error: + error instanceof Error ? error.message : 'failed to sign request', + }); + } + return; + } + + if (method === 'POST' && url.pathname === '/new_block') { + try { + const payload = await readJsonBody(request); + console.log( + `[watchtower] /new_block received ${summarizeNewBlockPayload(payload)}`, + ); + if (logRawEvents) { + const samples = extractRawStackflowPrintEventSamples( + payload, + watchedContracts, + ); + console.log( + `[watchtower] /new_block raw stackflow events count=${samples.length}`, + ); + for (const [index, sample] of samples.entries()) { + console.log( + `[watchtower] /new_block raw stackflow event[${index}] ${stringifyForLog(sample)}`, + ); + } + } + const result = await watchtower.ingest(payload, url.pathname); + console.log( + `[watchtower] /new_block processed observedEvents=${result.observedEvents} activeClosures=${result.activeClosures}`, + ); + writeJson(response, 200, { ok: true, ...result }); + } catch (error) { + console.error( + `[watchtower] /new_block error: ${ + error instanceof Error ? error.message : 'failed to ingest payload' + }`, + ); + writeJson(response, 400, { + ok: false, + error: + error instanceof Error ? error.message : 'failed to ingest payload', + }); + } + return; + } + + if (method === 'POST' && url.pathname === '/new_burn_block') { + try { + const payload = await readJsonBody(request); + const burnBlockHeight = extractBurnBlockHeight(payload); + + if (!burnBlockHeight) { + console.warn('[watchtower] /new_burn_block ignored: missing burn block height'); + writeJson(response, 200, { + ok: true, + ignored: true, + route: url.pathname, + reason: 'missing-burn-block-height', + }); + return; + } + + const result = await watchtower.ingestBurnBlock(burnBlockHeight, url.pathname); + writeJson(response, 200, { + ok: true, + ...result, + }); + } catch (error) { + console.error( + `[watchtower] /new_burn_block error: ${ + error instanceof Error ? error.message : 'failed to process burn block' + }`, + ); + writeJson(response, 200, { + ok: false, + ignored: true, + route: url.pathname, + error: + error instanceof Error + ? error.message + : 'failed to process burn block', + }); + } + return; + } + + if (method === 'POST' && STACKS_NODE_COMPAT_ROUTES.has(url.pathname)) { + try { + await discardBody(request); + } catch { + // Keep compatibility responses permissive to avoid observer retries. + } + + writeJson(response, 200, { + ok: true, + ignored: true, + route: url.pathname, + }); + return; + } + + writeJson(response, 404, { + ok: false, + error: 'route not found', + }); + }; +} + +function start(): void { + const config = loadConfig(); + const stateStore = new SqliteStateStore({ + dbFile: config.dbFile, + maxRecentEvents: config.maxRecentEvents, + }); + + stateStore.load(); + + const disputeExecutor: DisputeExecutor = (() => { + if (config.disputeExecutorMode === 'noop') { + return new NoopDisputeExecutor(); + } + + if (config.disputeExecutorMode === 'mock') { + return new MockDisputeExecutor(); + } + + return config.signerKey + ? new StacksDisputeExecutor(config) + : new NoopDisputeExecutor(); + })(); + + const signatureVerifier: SignatureVerifier = (() => { + if (config.signatureVerifierMode === 'accept-all') { + return new AcceptAllSignatureVerifier(); + } + + if (config.signatureVerifierMode === 'reject-all') { + return new RejectAllSignatureVerifier(); + } + + return new ReadOnlySignatureVerifier(config); + })(); + + const watchtower = new Watchtower({ + stateStore, + watchedContracts: config.watchedContracts, + watchedPrincipals: config.watchedPrincipals, + disputeExecutor, + disputeOnlyBeneficial: config.disputeOnlyBeneficial, + signatureVerifier, + }); + const producerSigner = createProducerSigner(config); + const producerService = new ProducerService({ + watchtower, + signer: producerSigner, + }); + + const startedAt = new Date().toISOString(); + const server = http.createServer( + createHandler({ + watchtower, + producerService, + startedAt, + disputeEnabled: disputeExecutor.enabled, + signerAddress: disputeExecutor.signerAddress, + producerEnabled: producerService.enabled, + producerPrincipal: producerService.producerPrincipal, + stacksNetwork: config.stacksNetwork, + watchedContracts: config.watchedContracts, + logRawEvents: config.logRawEvents, + }), + ); + + server.listen(config.port, config.host, () => { + const watchedContracts = + config.watchedContracts.length > 0 + ? config.watchedContracts.join(', ') + : '[auto: any *.stackflow* contract]'; + const watchedPrincipals = + config.watchedPrincipals.length > 0 + ? config.watchedPrincipals.join(', ') + : '[auto: any principal]'; + + console.log( + `[watchtower] listening on http://${config.host}:${config.port} ` + + `contracts=${watchedContracts} db=${config.dbFile} ` + + `principals=${watchedPrincipals} disputes=${disputeExecutor.enabled ? 'enabled' : 'disabled'} ` + + `dispute-mode=${config.disputeExecutorMode} verifier-mode=${config.signatureVerifierMode} ` + + `producer-signer-mode=${config.producerSignerMode} ` + + `producer-signing=${producerService.enabled ? 'enabled' : 'disabled'} producer-principal=${ + producerService.producerPrincipal ?? '-' + }`, + ); + + if (config.signatureVerifierMode !== 'readonly') { + console.warn( + `[watchtower] non-readonly signature verifier mode active: ${config.signatureVerifierMode}`, + ); + } + + if (config.disputeExecutorMode !== 'auto') { + console.warn( + `[watchtower] non-auto dispute executor mode active: ${config.disputeExecutorMode}`, + ); + } + + if (config.logRawEvents) { + console.warn('[watchtower] raw stackflow event logging is enabled'); + } + }); + + let shuttingDown = false; + const shutdown = (signal: string): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + + console.log(`[watchtower] received ${signal}, shutting down`); + server.close(() => { + stateStore.close(); + console.log('[watchtower] shutdown complete'); + process.exit(0); + }); + + setTimeout(() => { + console.error('[watchtower] forced shutdown timeout reached'); + stateStore.close(); + process.exit(1); + }, 10000).unref(); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +start(); diff --git a/server/src/observer-parser.ts b/server/src/observer-parser.ts new file mode 100644 index 0000000..d91d692 --- /dev/null +++ b/server/src/observer-parser.ts @@ -0,0 +1,399 @@ +import { cvToJSON, deserializeCV } from "@stacks/transactions"; + +import type { + PipeKey, + PipePendingSnapshot, + PipeSnapshot, + StackflowPrintEvent, +} from "./types.js"; + +const DEFAULT_STACKFLOW_CONTRACT_PATTERN = /\.stackflow(?:[-.].+)?$/i; + +interface CandidateContractEvent { + envelope: Record; + event: Record; +} + +interface ExtractOptions { + watchedContracts?: string[]; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isHexLikeString(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return false; + } + + if (trimmed.startsWith("0x")) { + return /^[0-9a-fA-F]+$/.test(trimmed.slice(2)); + } + + return /^[0-9a-fA-F]+$/.test(trimmed); +} + +function getFirstScalarString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + if (typeof value === "bigint") { + return value.toString(10); + } + } + return null; +} + +function unwrapClarityJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(unwrapClarityJson); + } + + if (!isRecord(value)) { + return value; + } + + const keys = Object.keys(value); + if (keys.length === 2 && keys.includes("type") && keys.includes("value")) { + const type = String(value.type); + const rawValue = value.value; + + if (type === "uint" || type === "int") { + return String(rawValue); + } + + return unwrapClarityJson(rawValue); + } + + const unwrapped: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + unwrapped[key] = unwrapClarityJson(nestedValue); + } + + return unwrapped; +} + +function extractHexValue(value: unknown): string | null { + if (!value) { + return null; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!isHexLikeString(trimmed)) { + return null; + } + return trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`; + } + + if (!isRecord(value)) { + return null; + } + + if (typeof value.hex === "string") { + return value.hex.startsWith("0x") ? value.hex : `0x${value.hex}`; + } + + if (typeof value.value === "string") { + return value.value.startsWith("0x") ? value.value : `0x${value.value}`; + } + + return null; +} + +function decodePrintValue( + ...values: unknown[] +): Record | null { + let hex: string | null = null; + + for (const value of values) { + if (!hex) { + hex = extractHexValue(value); + } + if (hex) { + break; + } + } + + if (!hex) { + return null; + } + + try { + const decoded = unwrapClarityJson(cvToJSON(deserializeCV(hex))); + return isRecord(decoded) ? decoded : null; + } catch { + return null; + } +} + +function normalizePipeKey(pipeKey: unknown): PipeKey | null { + if (!isRecord(pipeKey)) { + return null; + } + + const principal1 = pipeKey["principal-1"]; + const principal2 = pipeKey["principal-2"]; + + if (typeof principal1 !== "string" || typeof principal2 !== "string") { + return null; + } + + return { + "principal-1": principal1, + "principal-2": principal2, + token: typeof pipeKey.token === "string" ? pipeKey.token : null, + }; +} + +function normalizePipe(pipe: unknown): PipeSnapshot | null { + if (!isRecord(pipe)) { + return null; + } + + const normalizePending = (value: unknown): PipePendingSnapshot | null => { + if (!isRecord(value)) { + return null; + } + + return { + amount: typeof value.amount === "string" ? value.amount : null, + "burn-height": + typeof value["burn-height"] === "string" ? value["burn-height"] : null, + }; + }; + + return { + "balance-1": + typeof pipe["balance-1"] === "string" ? pipe["balance-1"] : null, + "balance-2": + typeof pipe["balance-2"] === "string" ? pipe["balance-2"] : null, + "pending-1": normalizePending(pipe["pending-1"]), + "pending-2": normalizePending(pipe["pending-2"]), + "expires-at": + typeof pipe["expires-at"] === "string" ? pipe["expires-at"] : null, + nonce: typeof pipe.nonce === "string" ? pipe.nonce : null, + closer: typeof pipe.closer === "string" ? pipe.closer : null, + }; +} + +function collectContractEventCandidates( + payload: unknown, +): CandidateContractEvent[] { + const queue: unknown[] = [payload]; + const visited = new Set(); + const candidates: CandidateContractEvent[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + + if (Array.isArray(current)) { + for (const item of current) { + queue.push(item); + } + continue; + } + + if (!isRecord(current)) { + continue; + } + + if (visited.has(current)) { + continue; + } + visited.add(current); + + if (isRecord(current.contract_event)) { + candidates.push({ envelope: current, event: current.contract_event }); + } + + if (isRecord(current.contract_log)) { + candidates.push({ envelope: current, event: current.contract_log }); + } + + if ( + typeof current.contract_identifier === "string" && + typeof current.topic === "string" && + current.raw_value !== undefined && + (current.txid !== undefined || + current.tx_id !== undefined || + current.event_index !== undefined || + current.eventIndex !== undefined) + ) { + candidates.push({ envelope: current, event: current }); + } + + for (const value of Object.values(current)) { + queue.push(value); + } + } + + return candidates; +} + +function contractMatches( + contractId: string | null, + watchedContracts: string[], +): boolean { + if (!contractId) { + return false; + } + + if (watchedContracts.length > 0) { + return watchedContracts.includes(contractId); + } + + return DEFAULT_STACKFLOW_CONTRACT_PATTERN.test(contractId); +} + +function normalizeContractEvent( + payload: unknown, + candidate: CandidateContractEvent, + watchedContracts: string[], +): StackflowPrintEvent | null { + const { envelope, event } = candidate; + + const contractId = getFirstScalarString( + event.contract_identifier, + event.contract_id, + event.contractId, + envelope.contract_identifier, + envelope.contract_id, + ); + + if (!contractId || !contractMatches(contractId, watchedContracts)) { + return null; + } + + const topic = getFirstScalarString(event.topic, envelope.topic); + if (topic !== "print") { + return null; + } + + const payloadRecord = isRecord(payload) ? payload : {}; + + const txid = getFirstScalarString( + event.txid, + event.tx_id, + envelope.txid, + envelope.tx_id, + ); + const blockHeight = getFirstScalarString( + event.block_height, + event.blockHeight, + envelope.block_height, + envelope.blockHeight, + payloadRecord.block_height, + payloadRecord.blockHeight, + ); + const blockHash = getFirstScalarString( + event.block_hash, + event.blockHash, + envelope.block_hash, + envelope.blockHash, + payloadRecord.block_hash, + payloadRecord.blockHash, + ); + const eventIndex = getFirstScalarString( + event.event_index, + event.eventIndex, + envelope.event_index, + envelope.eventIndex, + ); + + const decoded = decodePrintValue( + event.raw_value, + event.rawValue, + envelope.raw_value, + envelope.rawValue, + ); + const eventName = + getFirstScalarString( + decoded && typeof decoded.event === "string" ? decoded.event : null, + event.event_name, + event.eventName, + ) || null; + + return { + contractId, + topic: "print", + txid, + blockHeight, + blockHash, + eventIndex, + eventName, + sender: + getFirstScalarString( + decoded && typeof decoded.sender === "string" ? decoded.sender : null, + event.sender, + envelope.sender, + ) || null, + pipeKey: normalizePipeKey( + (decoded ? decoded["pipe-key"] : null) ?? + event["pipe-key"] ?? + envelope["pipe-key"], + ), + pipe: normalizePipe( + (decoded ? decoded.pipe : null) ?? event.pipe ?? envelope.pipe, + ), + repr: null, + }; +} + +function dedupeEvents(events: StackflowPrintEvent[]): StackflowPrintEvent[] { + const seen = new Set(); + const output: StackflowPrintEvent[] = []; + + for (const event of events) { + const dedupeKey = [ + event.txid, + event.eventIndex, + event.contractId, + event.eventName, + event.sender, + event.pipeKey ? normalizePipeId(event.pipeKey) : null, + ].join("|"); + + if (seen.has(dedupeKey)) { + continue; + } + + seen.add(dedupeKey); + output.push(event); + } + + return output; +} + +export function normalizePipeId(pipeKey: PipeKey | null): string | null { + if (!pipeKey) { + return null; + } + + const token = pipeKey.token || "stx"; + return `${token}|${pipeKey["principal-1"]}|${pipeKey["principal-2"]}`; +} + +export function extractStackflowPrintEvents( + payload: unknown, + options: ExtractOptions = {}, +): StackflowPrintEvent[] { + const watchedContracts = options.watchedContracts || []; + const candidates = collectContractEventCandidates(payload); + + const normalized = candidates + .map((candidate) => + normalizeContractEvent(payload, candidate, watchedContracts), + ) + .filter((event): event is StackflowPrintEvent => event !== null); + + return dedupeEvents(normalized); +} diff --git a/server/src/principal-utils.ts b/server/src/principal-utils.ts new file mode 100644 index 0000000..c715279 --- /dev/null +++ b/server/src/principal-utils.ts @@ -0,0 +1,137 @@ +import { principalCV, serializeCV } from '@stacks/transactions'; + +import type { PipeKey } from './types.js'; + +function parseHexBytes(hex: string): Uint8Array { + const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; + return Uint8Array.from(Buffer.from(normalized, 'hex')); +} + +function compareBytes(left: Uint8Array, right: Uint8Array): number { + const minLength = Math.min(left.length, right.length); + for (let index = 0; index < minLength; index += 1) { + if (left[index] < right[index]) { + return -1; + } + if (left[index] > right[index]) { + return 1; + } + } + + if (left.length < right.length) { + return -1; + } + if (left.length > right.length) { + return 1; + } + + return 0; +} + +export function normalizeHex(input: string): string { + const value = input.trim(); + return value.startsWith('0x') ? value.toLowerCase() : `0x${value.toLowerCase()}`; +} + +export function isValidHex(input: string, bytes?: number): boolean { + const value = normalizeHex(input); + if (!/^0x[0-9a-f]+$/i.test(value)) { + return false; + } + + if (bytes === undefined) { + return (value.length - 2) % 2 === 0; + } + + return value.length === bytes * 2 + 2; +} + +export function hexToBytes(input: string): Uint8Array { + return parseHexBytes(normalizeHex(input)); +} + +export function parseOptionalUInt(value: unknown): string | null { + if (value === null || value === undefined || value === '') { + return null; + } + + return parseUInt(value); +} + +export function parseUInt(value: unknown): string { + if (typeof value === 'bigint') { + if (value < 0n) { + throw new Error('value must be a uint'); + } + return value.toString(10); + } + + if (typeof value === 'number') { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + throw new Error('value must be a uint'); + } + return String(value); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new Error('value must be a uint'); + } + return BigInt(trimmed).toString(10); + } + + throw new Error('value must be a uint'); +} + +export function splitContractId(contractId: string): { + address: string; + name: string; +} { + const dot = contractId.indexOf('.'); + if (dot <= 0 || dot === contractId.length - 1) { + throw new Error('invalid contract id'); + } + + return { + address: contractId.slice(0, dot), + name: contractId.slice(dot + 1), + }; +} + +export function canonicalPipeKey( + token: string | null, + leftPrincipal: string, + rightPrincipal: string, +): PipeKey { + if (leftPrincipal === rightPrincipal) { + throw new Error('forPrincipal and withPrincipal must be different'); + } + + const leftBytes = parseHexBytes(serializeCV(principalCV(leftPrincipal))); + const rightBytes = parseHexBytes(serializeCV(principalCV(rightPrincipal))); + + if (compareBytes(leftBytes, rightBytes) <= 0) { + return { + token, + 'principal-1': leftPrincipal, + 'principal-2': rightPrincipal, + }; + } + + return { + token, + 'principal-1': rightPrincipal, + 'principal-2': leftPrincipal, + }; +} + +export function parsePrincipal(input: unknown, fieldName: string): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new Error(`${fieldName} must be a principal string`); + } + + const value = input.trim(); + principalCV(value); + return value; +} diff --git a/server/src/producer-service.ts b/server/src/producer-service.ts new file mode 100644 index 0000000..7472993 --- /dev/null +++ b/server/src/producer-service.ts @@ -0,0 +1,831 @@ +import { createHash } from 'node:crypto'; + +import { createNetwork } from '@stacks/network'; +import { + ClarityType, + bufferCV, + fetchCallReadOnlyFunction, + getAddressFromPrivateKey, + noneCV, + principalCV, + signStructuredData, + someCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; + +import { + canonicalPipeKey, + hexToBytes, + isValidHex, + normalizeHex, + parseOptionalUInt, + parsePrincipal, + parseUInt, + splitContractId, +} from './principal-utils.js'; +import { normalizePipeId } from './observer-parser.js'; +import { describeStackflowContractError } from './signature-verifier.js'; +import type { + ProducerSignerMode, + SignatureStateUpsertResult, + SignatureVerificationResult, + SignatureVerifierMode, + WatchtowerConfig, +} from './types.js'; +import { + PrincipalNotWatchedError, + SignatureValidationError, + Watchtower, +} from './watchtower.js'; + +const ACTION_CLOSE = '0'; +const ACTION_TRANSFER = '1'; +const ACTION_DEPOSIT = '2'; +const ACTION_WITHDRAWAL = '3'; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeContractId(input: unknown): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new ProducerServiceError(400, 'contractId must be a non-empty string'); + } + + const contractId = input.trim(); + try { + splitContractId(contractId); + } catch { + throw new ProducerServiceError(400, 'invalid contractId'); + } + return contractId; +} + +function normalizeToken(input: unknown): string | null { + if (input === null || input === undefined || input === '') { + return null; + } + + try { + return parsePrincipal(input, 'token'); + } catch (error) { + throw new ProducerServiceError( + 400, + error instanceof Error ? error.message : 'token must be a principal', + ); + } +} + +function normalizeHexBuff(input: unknown, bytes: number, fieldName: string): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new ProducerServiceError(400, `${fieldName} must be a hex string`); + } + + const value = input.trim().toLowerCase(); + if (!isValidHex(value, bytes)) { + throw new ProducerServiceError(400, `${fieldName} must be ${bytes} bytes of hex`); + } + + return value.startsWith('0x') ? value : `0x${value}`; +} + +function normalizeOptionalHexBuff( + input: unknown, + bytes: number, + fieldName: string, +): string | null { + if (input === null || input === undefined || input === '') { + return null; + } + + return normalizeHexBuff(input, bytes, fieldName); +} + +function normalizeBool(input: unknown, fallback: boolean): boolean { + if (input === undefined || input === null || input === '') { + return fallback; + } + + if (typeof input === 'boolean') { + return input; + } + + const normalized = String(input).trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + + throw new ProducerServiceError(400, 'beneficialOnly must be a boolean'); +} + +function chainIdForNetwork(network: WatchtowerConfig['stacksNetwork']): bigint { + if (network === 'mainnet') { + return 1n; + } + return 2_147_483_648n; +} + +function senderAddressForPrincipal(principal: string): string { + if (principal.includes('.')) { + return splitContractId(principal).address; + } + return principal; +} + +function parsePrincipalField(value: unknown, fieldName: string): string { + try { + return parsePrincipal(value, fieldName); + } catch (error) { + throw new ProducerServiceError( + 400, + error instanceof Error + ? error.message + : `${fieldName} must be a principal string`, + ); + } +} + +function parseUIntField(value: unknown, fieldName: string): string { + try { + return parseUInt(value); + } catch { + throw new ProducerServiceError(400, `${fieldName} must be a uint`); + } +} + +function parseOptionalUIntField(value: unknown, fieldName: string): string | null { + try { + return parseOptionalUInt(value); + } catch { + throw new ProducerServiceError(400, `${fieldName} must be a uint`); + } +} + +type ProducerStateSource = 'onchain' | 'signature-state'; + +interface ProducerStateBaseline { + source: ProducerStateSource; + nonce: string; + nonceValue: bigint; + myBalance: string; + myBalanceValue: bigint; + theirBalance: string; + theirBalanceValue: bigint; + updatedAt: string; +} + +function parseUnsignedBigInt(value: string | null): bigint | null { + if (value === null || !/^\d+$/.test(value)) { + return null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + +function shouldReplaceBaseline( + existing: ProducerStateBaseline, + incoming: ProducerStateBaseline, +): boolean { + if (incoming.nonceValue !== existing.nonceValue) { + return incoming.nonceValue > existing.nonceValue; + } + + if (incoming.updatedAt !== existing.updatedAt) { + return incoming.updatedAt > existing.updatedAt; + } + + if (incoming.source !== existing.source) { + return incoming.source === 'onchain'; + } + + return false; +} + +export interface ProducerSignRequest { + contractId: string; + forPrincipal: string; + withPrincipal: string; + token: string | null; + amount: string; + myBalance: string; + theirBalance: string; + theirSignature: string; + nonce: string; + action: string; + actor: string; + secret: string | null; + validAfter: string | null; + beneficialOnly: boolean; +} + +interface ParseProducerSignRequestOptions { + producerPrincipal: string; + allowedActions: Set; + defaultAction: string | null; +} + +function parseProducerSignRequest( + input: unknown, + options: ParseProducerSignRequestOptions, +): ProducerSignRequest { + if (!isRecord(input)) { + throw new ProducerServiceError(400, 'payload must be an object'); + } + + const data = input; + const forPrincipalInput = data.forPrincipal; + if (forPrincipalInput !== undefined && forPrincipalInput !== null && forPrincipalInput !== '') { + const parsedForPrincipal = parsePrincipalField(forPrincipalInput, 'forPrincipal'); + if (parsedForPrincipal !== options.producerPrincipal) { + throw new ProducerServiceError( + 400, + `forPrincipal must match producer principal ${options.producerPrincipal}`, + ); + } + } + + const actionInput = + data.action !== undefined && data.action !== null && data.action !== '' + ? data.action + : options.defaultAction; + if (actionInput === null) { + throw new ProducerServiceError(400, 'action is required'); + } + + const action = parseUIntField(actionInput, 'action'); + if (!options.allowedActions.has(action)) { + throw new ProducerServiceError( + 400, + `action ${action} is not allowed for this endpoint`, + ); + } + + const amount = + action === ACTION_DEPOSIT || action === ACTION_WITHDRAWAL + ? parseUIntField(data.amount, 'amount') + : parseOptionalUIntField(data.amount, 'amount') || '0'; + + return { + contractId: normalizeContractId(data.contractId), + forPrincipal: options.producerPrincipal, + withPrincipal: parsePrincipalField(data.withPrincipal, 'withPrincipal'), + token: normalizeToken(data.token), + amount, + myBalance: parseUIntField(data.myBalance, 'myBalance'), + theirBalance: parseUIntField(data.theirBalance, 'theirBalance'), + theirSignature: normalizeHexBuff( + data.theirSignature ?? data.counterpartySignature, + 65, + 'theirSignature', + ), + nonce: parseUIntField(data.nonce, 'nonce'), + action, + actor: parsePrincipalField(data.actor, 'actor'), + secret: normalizeOptionalHexBuff(data.secret, 32, 'secret'), + validAfter: parseOptionalUIntField(data.validAfter, 'validAfter'), + beneficialOnly: normalizeBool(data.beneficialOnly, false), + }; +} + +export interface ProducerSigner { + readonly enabled: boolean; + readonly producerPrincipal: string | null; + readonly signerAddress: string | null; + verifyCounterpartySignature( + request: ProducerSignRequest, + ): Promise; + signMySignature(request: ProducerSignRequest): string; +} + +export class ProducerStateSigner implements ProducerSigner { + readonly enabled: boolean; + + readonly producerPrincipal: string | null; + + readonly signerAddress: string | null; + + private readonly producerKey: string | null; + + private readonly network: ReturnType; + + private readonly signatureVerifierMode: SignatureVerifierMode; + + private readonly stackflowMessageVersion: string; + + private readonly stacksNetwork: WatchtowerConfig['stacksNetwork']; + + constructor( + config: Pick< + WatchtowerConfig, + | 'stacksNetwork' + | 'stacksApiUrl' + | 'signatureVerifierMode' + | 'producerKey' + | 'producerPrincipal' + | 'stackflowMessageVersion' + >, + ) { + this.network = createNetwork({ + network: config.stacksNetwork, + client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, + }); + + this.signatureVerifierMode = config.signatureVerifierMode; + this.stackflowMessageVersion = config.stackflowMessageVersion; + this.stacksNetwork = config.stacksNetwork; + this.producerKey = config.producerKey + ? normalizeHex(config.producerKey).slice(2) + : null; + this.signerAddress = this.producerKey + ? getAddressFromPrivateKey(this.producerKey, this.network) + : null; + this.enabled = Boolean(this.producerKey); + + if (!this.enabled) { + this.producerPrincipal = null; + return; + } + + if (config.producerPrincipal?.trim()) { + const parsedProducerPrincipal = parsePrincipal( + config.producerPrincipal, + 'WATCHTOWER_PRODUCER_PRINCIPAL', + ); + if ( + !parsedProducerPrincipal.includes('.') && + parsedProducerPrincipal !== this.signerAddress + ) { + throw new Error( + `WATCHTOWER_PRODUCER_PRINCIPAL (${parsedProducerPrincipal}) does not match producer key address (${this.signerAddress})`, + ); + } + this.producerPrincipal = parsedProducerPrincipal; + return; + } + + this.producerPrincipal = this.signerAddress; + } + + async verifyCounterpartySignature( + request: ProducerSignRequest, + ): Promise { + if (!this.enabled || !this.producerPrincipal) { + return { valid: false, reason: 'producer signing is not configured' }; + } + + if (request.forPrincipal !== this.producerPrincipal) { + return { + valid: false, + reason: `forPrincipal must be ${this.producerPrincipal}`, + }; + } + + if (this.signatureVerifierMode === 'accept-all') { + return { valid: true, reason: null }; + } + if (this.signatureVerifierMode === 'reject-all') { + return { valid: false, reason: 'invalid-signature' }; + } + + const contract = splitContractId(request.contractId); + const pipeKey = canonicalPipeKey( + request.token, + request.forPrincipal, + request.withPrincipal, + ); + const balance1 = + pipeKey['principal-1'] === request.forPrincipal + ? request.myBalance + : request.theirBalance; + const balance2 = + pipeKey['principal-1'] === request.forPrincipal + ? request.theirBalance + : request.myBalance; + + const tokenArg = request.token ? someCV(principalCV(request.token)) : noneCV(); + const secretArg = request.secret + ? someCV(bufferCV(hexToBytes(request.secret))) + : noneCV(); + const validAfterArg = request.validAfter + ? someCV(uintCV(BigInt(request.validAfter))) + : noneCV(); + + const response = await fetchCallReadOnlyFunction({ + network: this.network, + senderAddress: senderAddressForPrincipal(this.producerPrincipal), + contractAddress: contract.address, + contractName: contract.name, + functionName: 'verify-signature-request', + functionArgs: [ + bufferCV(hexToBytes(request.theirSignature)), + principalCV(request.withPrincipal), + tupleCV({ + token: tokenArg, + 'principal-1': principalCV(pipeKey['principal-1']), + 'principal-2': principalCV(pipeKey['principal-2']), + }), + uintCV(BigInt(balance1)), + uintCV(BigInt(balance2)), + uintCV(BigInt(request.nonce)), + uintCV(BigInt(request.action)), + principalCV(request.actor), + secretArg, + validAfterArg, + uintCV(BigInt(request.amount)), + ], + }); + + if (response.type === ClarityType.ResponseErr) { + if (response.value.type === ClarityType.UInt) { + return { + valid: false, + reason: describeStackflowContractError(response.value.value), + }; + } + return { valid: false, reason: 'contract error' }; + } + + if (response.type !== ClarityType.ResponseOk) { + return { valid: false, reason: 'unexpected-readonly-response' }; + } + + return { valid: true, reason: null }; + } + + signMySignature(request: ProducerSignRequest): string { + if (!this.enabled || !this.producerKey || !this.producerPrincipal) { + throw new ProducerServiceError(503, 'producer signing is not configured'); + } + + const pipeKey = canonicalPipeKey( + request.token, + request.forPrincipal, + request.withPrincipal, + ); + const balance1 = + pipeKey['principal-1'] === request.forPrincipal + ? request.myBalance + : request.theirBalance; + const balance2 = + pipeKey['principal-1'] === request.forPrincipal + ? request.theirBalance + : request.myBalance; + + const hashedSecret = request.secret + ? someCV(bufferCV(createHash('sha256').update(hexToBytes(request.secret)).digest())) + : noneCV(); + const validAfter = request.validAfter + ? someCV(uintCV(BigInt(request.validAfter))) + : noneCV(); + const token = request.token ? someCV(principalCV(request.token)) : noneCV(); + + const message = tupleCV({ + token, + 'principal-1': principalCV(pipeKey['principal-1']), + 'principal-2': principalCV(pipeKey['principal-2']), + 'balance-1': uintCV(BigInt(balance1)), + 'balance-2': uintCV(BigInt(balance2)), + nonce: uintCV(BigInt(request.nonce)), + action: uintCV(BigInt(request.action)), + actor: principalCV(request.actor), + 'hashed-secret': hashedSecret, + 'valid-after': validAfter, + }); + + const domain = tupleCV({ + name: stringAsciiCV(request.contractId), + version: stringAsciiCV(this.stackflowMessageVersion), + 'chain-id': uintCV(chainIdForNetwork(this.stacksNetwork)), + }); + + const signature = signStructuredData({ + message, + domain, + privateKey: this.producerKey, + }); + return normalizeHex(signature); + } +} + +class UnsupportedProducerSigner implements ProducerSigner { + readonly enabled = false; + + readonly producerPrincipal = null; + + readonly signerAddress = null; + + private readonly reason: string; + + constructor(reason: string) { + this.reason = reason; + } + + async verifyCounterpartySignature( + _request: ProducerSignRequest, + ): Promise { + return { + valid: false, + reason: this.reason, + }; + } + + signMySignature(_request: ProducerSignRequest): string { + throw new ProducerServiceError(503, this.reason); + } +} + +export function createProducerSigner( + config: Pick< + WatchtowerConfig, + | 'stacksNetwork' + | 'stacksApiUrl' + | 'signatureVerifierMode' + | 'producerKey' + | 'producerPrincipal' + | 'producerSignerMode' + | 'stackflowMessageVersion' + >, +): ProducerSigner { + const mode = (config.producerSignerMode || 'local-key') as ProducerSignerMode; + + if (mode === 'kms') { + return new UnsupportedProducerSigner( + 'WATCHTOWER_PRODUCER_SIGNER_MODE=kms is not implemented yet', + ); + } + + return new ProducerStateSigner(config); +} + +export interface ProducerSignResult { + request: ProducerSignRequest; + mySignature: string; + upsert: SignatureStateUpsertResult; +} + +export class ProducerService { + private readonly watchtower: Watchtower; + + private readonly signer: ProducerSigner; + + constructor({ watchtower, signer }: { watchtower: Watchtower; signer: ProducerSigner }) { + this.watchtower = watchtower; + this.signer = signer; + } + + get enabled(): boolean { + return this.signer.enabled; + } + + get producerPrincipal(): string | null { + return this.signer.producerPrincipal; + } + + async signTransfer(payload: unknown): Promise { + return this.signState(payload, new Set([ACTION_TRANSFER]), ACTION_TRANSFER); + } + + async signSignatureRequest(payload: unknown): Promise { + return this.signState( + payload, + new Set([ACTION_CLOSE, ACTION_DEPOSIT, ACTION_WITHDRAWAL]), + null, + ); + } + + private resolveCurrentBaseline( + request: ProducerSignRequest, + ): ProducerStateBaseline | null { + const pipeKey = canonicalPipeKey( + request.token, + request.forPrincipal, + request.withPrincipal, + ); + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + return null; + } + + const status = this.watchtower.status(); + let best: ProducerStateBaseline | null = null; + + const consider = (candidate: ProducerStateBaseline): void => { + if (!best || shouldReplaceBaseline(best, candidate)) { + best = candidate; + } + }; + + for (const observed of status.observedPipes) { + if (observed.contractId !== request.contractId || observed.pipeId !== pipeId) { + continue; + } + + const principal1IsProducer = observed.pipeKey['principal-1'] === request.forPrincipal; + const myBalance = principal1IsProducer ? observed.balance1 : observed.balance2; + const theirBalance = principal1IsProducer ? observed.balance2 : observed.balance1; + const nonceValue = parseUnsignedBigInt(observed.nonce); + const myBalanceValue = parseUnsignedBigInt(myBalance); + const theirBalanceValue = parseUnsignedBigInt(theirBalance); + if ( + nonceValue === null || + myBalanceValue === null || + theirBalanceValue === null + ) { + continue; + } + + consider({ + source: 'onchain', + nonce: observed.nonce as string, + nonceValue, + myBalance: myBalance as string, + myBalanceValue, + theirBalance: theirBalance as string, + theirBalanceValue, + updatedAt: observed.updatedAt, + }); + } + + for (const signature of status.signatureStates) { + if ( + signature.contractId !== request.contractId || + signature.pipeId !== pipeId || + signature.forPrincipal !== request.forPrincipal + ) { + continue; + } + + const nonceValue = parseUnsignedBigInt(signature.nonce); + const myBalanceValue = parseUnsignedBigInt(signature.myBalance); + const theirBalanceValue = parseUnsignedBigInt(signature.theirBalance); + if ( + nonceValue === null || + myBalanceValue === null || + theirBalanceValue === null + ) { + continue; + } + + consider({ + source: 'signature-state', + nonce: signature.nonce, + nonceValue, + myBalance: signature.myBalance, + myBalanceValue, + theirBalance: signature.theirBalance, + theirBalanceValue, + updatedAt: signature.updatedAt, + }); + } + + return best; + } + + private enforceSigningPolicy(request: ProducerSignRequest): void { + const baseline = this.resolveCurrentBaseline(request); + if (!baseline) { + throw new ProducerServiceError(409, 'unknown-pipe-state', { + reason: 'unknown-pipe-state', + }); + } + + const incomingNonce = BigInt(request.nonce); + if (incomingNonce <= baseline.nonceValue) { + throw new ProducerServiceError(409, 'nonce-too-low', { + reason: 'nonce-too-low', + incomingNonce: request.nonce, + existingNonce: baseline.nonce, + state: { + source: baseline.source, + nonce: baseline.nonce, + myBalance: baseline.myBalance, + theirBalance: baseline.theirBalance, + updatedAt: baseline.updatedAt, + }, + }); + } + + const requestedMyBalance = BigInt(request.myBalance); + const requestedTheirBalance = BigInt(request.theirBalance); + + if (requestedMyBalance < baseline.myBalanceValue) { + throw new ProducerServiceError(403, 'producer-balance-decrease-not-allowed', { + reason: 'producer-balance-decrease', + currentMyBalance: baseline.myBalance, + requestedMyBalance: request.myBalance, + }); + } + + if (request.action === ACTION_TRANSFER) { + const currentTotal = baseline.myBalanceValue + baseline.theirBalanceValue; + const requestedTotal = requestedMyBalance + requestedTheirBalance; + if (requestedTotal !== currentTotal) { + throw new ProducerServiceError(403, 'invalid-transfer-total', { + reason: 'invalid-transfer-total', + currentTotal: currentTotal.toString(10), + requestedTotal: requestedTotal.toString(10), + }); + } + + if (requestedMyBalance <= baseline.myBalanceValue) { + throw new ProducerServiceError(403, 'transfer-not-beneficial-for-producer', { + reason: 'transfer-not-beneficial', + currentMyBalance: baseline.myBalance, + requestedMyBalance: request.myBalance, + }); + } + } + } + + private async signState( + payload: unknown, + allowedActions: Set, + defaultAction: string | null, + ): Promise { + if (!this.signer.producerPrincipal) { + throw new ProducerServiceError(503, 'producer signing is not configured'); + } + + const request = parseProducerSignRequest(payload, { + producerPrincipal: this.signer.producerPrincipal, + allowedActions, + defaultAction, + }); + + this.enforceSigningPolicy(request); + + const verification = await this.signer.verifyCounterpartySignature(request); + if (!verification.valid) { + throw new ProducerServiceError( + 401, + verification.reason || 'counterparty signature invalid', + ); + } + + const mySignature = this.signer.signMySignature(request); + + try { + const upsert = await this.watchtower.upsertSignatureState({ + contractId: request.contractId, + forPrincipal: request.forPrincipal, + withPrincipal: request.withPrincipal, + token: request.token, + amount: request.amount, + myBalance: request.myBalance, + theirBalance: request.theirBalance, + mySignature, + theirSignature: request.theirSignature, + nonce: request.nonce, + action: request.action, + actor: request.actor, + secret: request.secret, + validAfter: request.validAfter, + beneficialOnly: request.beneficialOnly, + }, { + skipVerification: true, + }); + + return { + request, + mySignature, + upsert, + }; + } catch (error) { + if (error instanceof SignatureValidationError) { + throw new ProducerServiceError(401, error.message); + } + + if (error instanceof PrincipalNotWatchedError) { + throw new ProducerServiceError(403, error.message); + } + + throw error; + } + } +} + +export class ProducerServiceError extends Error { + readonly statusCode: number; + + readonly details: Record | null; + + constructor( + statusCode: number, + message: string, + details: Record | null = null, + ) { + super(message); + this.name = 'ProducerServiceError'; + this.statusCode = statusCode; + this.details = details; + } +} diff --git a/server/src/signature-verifier.ts b/server/src/signature-verifier.ts new file mode 100644 index 0000000..47477a0 --- /dev/null +++ b/server/src/signature-verifier.ts @@ -0,0 +1,197 @@ +import { createNetwork } from '@stacks/network'; +import { + ClarityType, + bufferCV, + fetchCallReadOnlyFunction, + noneCV, + principalCV, + someCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; + +import { canonicalPipeKey, hexToBytes, splitContractId } from './principal-utils.js'; +import type { + SignatureStateInput, + SignatureVerificationResult, + SignatureVerifier, + WatchtowerConfig, +} from './types.js'; + +const STACKFLOW_CONTRACT_ERROR_MESSAGES: Record = { + '100': 'deposit failed', + '101': 'no such pipe', + '102': 'invalid principal', + '103': 'invalid sender signature', + '104': 'invalid other signature', + '105': 'consensus serialization failed', + '106': 'unauthorized', + '107': 'max allowed exceeded', + '108': 'invalid total balance', + '109': 'withdrawal failed', + '110': 'pipe expired', + '111': 'nonce too low', + '112': 'close in progress', + '113': 'no close in progress', + '114': 'self dispute is not allowed', + '115': 'already funded', + '116': 'invalid withdrawal', + '117': 'unapproved token', + '118': 'not expired', + '119': 'contract not initialized', + '120': 'contract already initialized', + '121': 'transfer not valid yet', + '122': 'already pending', + '123': 'pending deposit exists', + '124': 'invalid balances', + '125': 'invalid signature', + '126': 'allowance violation', + '127': 'self-pipe is not allowed', +}; + +export function describeStackflowContractError(code: string | number | bigint): string { + const codeText = String(code); + const message = STACKFLOW_CONTRACT_ERROR_MESSAGES[codeText]; + if (message) { + return `${message} (contract err u${codeText})`; + } + + return `contract error u${codeText}`; +} + +function senderAddressForPrincipal(principal: string): string { + if (principal.includes('.')) { + return splitContractId(principal).address; + } + return principal; +} + +export class ReadOnlySignatureVerifier implements SignatureVerifier { + private readonly network: ReturnType; + + constructor(config: Pick) { + this.network = createNetwork({ + network: config.stacksNetwork, + client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, + }); + } + + async verifySignatureState( + input: SignatureStateInput, + ): Promise { + const contract = splitContractId(input.contractId); + const pipeKey = canonicalPipeKey( + input.token, + input.forPrincipal, + input.withPrincipal, + ); + + const balance1 = + pipeKey['principal-1'] === input.forPrincipal + ? input.myBalance + : input.theirBalance; + const balance2 = + pipeKey['principal-1'] === input.forPrincipal + ? input.theirBalance + : input.myBalance; + + const tokenArg = input.token ? someCV(principalCV(input.token)) : noneCV(); + const secretArg = input.secret + ? someCV(bufferCV(hexToBytes(input.secret))) + : noneCV(); + const validAfterArg = input.validAfter + ? someCV(uintCV(BigInt(input.validAfter))) + : noneCV(); + + const functionArgs = ( + signature: string, + signer: string, + ) => [ + bufferCV(hexToBytes(signature)), + principalCV(signer), + tupleCV({ + token: tokenArg, + 'principal-1': principalCV(pipeKey['principal-1']), + 'principal-2': principalCV(pipeKey['principal-2']), + }), + uintCV(BigInt(balance1)), + uintCV(BigInt(balance2)), + uintCV(BigInt(input.nonce)), + uintCV(BigInt(input.action)), + principalCV(input.actor), + secretArg, + validAfterArg, + uintCV(BigInt(input.amount)), + ]; + + const verifyOne = async ( + signature: string, + signer: string, + ): Promise => { + const response = await fetchCallReadOnlyFunction({ + network: this.network, + senderAddress: senderAddressForPrincipal(input.forPrincipal), + contractAddress: contract.address, + contractName: contract.name, + functionName: 'verify-signature-request', + functionArgs: functionArgs(signature, signer), + }); + + if (response.type === ClarityType.ResponseErr) { + if (response.value.type === ClarityType.UInt) { + return { + valid: false, + reason: describeStackflowContractError(response.value.value), + }; + } + + return { valid: false, reason: 'contract error' }; + } + + if (response.type !== ClarityType.ResponseOk) { + return { valid: false, reason: 'unexpected-readonly-response' }; + } + + if ( + response.value.type === ClarityType.OptionalNone || + response.value.type === ClarityType.OptionalSome + ) { + return { valid: true, reason: null }; + } + + return { + valid: false, + reason: 'verify-signature-request-returned-unexpected-value', + }; + }; + + const myVerification = await verifyOne(input.mySignature, input.forPrincipal); + if (!myVerification.valid) { + return myVerification; + } + + return verifyOne(input.theirSignature, input.withPrincipal); + } +} + +export class AcceptAllSignatureVerifier implements SignatureVerifier { + async verifySignatureState( + _input: SignatureStateInput, + ): Promise { + return { valid: true, reason: null }; + } +} + +export class RejectAllSignatureVerifier implements SignatureVerifier { + private readonly reason: string; + + constructor(reason = 'invalid-signature') { + this.reason = reason; + } + + async verifySignatureState( + _input: SignatureStateInput, + ): Promise { + return { valid: false, reason: this.reason }; + } +} diff --git a/server/src/state-store.ts b/server/src/state-store.ts new file mode 100644 index 0000000..88da899 --- /dev/null +++ b/server/src/state-store.ts @@ -0,0 +1,1009 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import type { + ClosureRecord, + DisputeAttemptRecord, + ObservedPipeRecord, + PipeKey, + RecordedWatchtowerEvent, + SignatureStateRecord, + WatchtowerPersistedState, +} from './types.js'; + +interface SqliteStateStoreOptions { + dbFile: string; + maxRecentEvents?: number; +} + +interface ClosureRow { + pipe_id: string; + contract_id: string; + pipe_key_json: string; + closer: string | null; + expires_at: string | null; + nonce: string | null; + event: string; + txid: string | null; + block_height: string | null; + updated_at: string; +} + +interface ObservedPipeRow { + state_id: string; + pipe_id: string; + contract_id: string; + pipe_key_json: string; + balance_1: string | null; + balance_2: string | null; + pending_1_amount: string | null; + pending_1_burn_height: string | null; + pending_2_amount: string | null; + pending_2_burn_height: string | null; + expires_at: string | null; + nonce: string | null; + closer: string | null; + event: string; + txid: string | null; + block_height: string | null; + updated_at: string; +} + +interface SignatureStateRow { + state_id: string; + pipe_id: string; + contract_id: string; + for_principal: string; + with_principal: string; + token: string | null; + amount: string; + my_balance: string; + their_balance: string; + my_signature: string; + their_signature: string; + nonce: string; + action: string; + actor: string; + secret: string | null; + valid_after: string | null; + beneficial_only: number; + updated_at: string; +} + +interface DisputeAttemptRow { + attempt_id: string; + contract_id: string; + pipe_id: string; + for_principal: string; + trigger_txid: string | null; + success: number; + dispute_txid: string | null; + error: string | null; + created_at: string; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function isLegacyState(value: unknown): value is WatchtowerPersistedState { + if (!isRecord(value)) { + return false; + } + + const observedPipes = value.observedPipes; + const observedPipesOk = + observedPipes === undefined || isRecord(observedPipes); + + return ( + isRecord(value.activeClosures) && + observedPipesOk && + isRecord(value.signatureStates) && + isRecord(value.disputeAttempts) && + Array.isArray(value.recentEvents) + ); +} + +function parsePipeKey(value: string): PipeKey | null { + try { + const parsed = JSON.parse(value); + if (!isRecord(parsed)) { + return null; + } + + const principal1 = parsed['principal-1']; + const principal2 = parsed['principal-2']; + const token = parsed.token; + + if (typeof principal1 !== 'string' || typeof principal2 !== 'string') { + return null; + } + + return { + 'principal-1': principal1, + 'principal-2': principal2, + token: typeof token === 'string' ? token : null, + }; + } catch { + return null; + } +} + +export class SqliteStateStore { + private readonly dbFile: string; + + private readonly maxRecentEvents: number; + + private db: DatabaseSync | null; + + constructor({ dbFile, maxRecentEvents = 500 }: SqliteStateStoreOptions) { + this.dbFile = dbFile; + this.maxRecentEvents = maxRecentEvents; + this.db = null; + } + + load(): void { + const directory = path.dirname(this.dbFile); + fs.mkdirSync(directory, { recursive: true }); + + const legacyState = this.loadLegacyJsonState(); + if (legacyState) { + const backupFile = `${this.dbFile}.json-backup-${Date.now()}`; + fs.renameSync(this.dbFile, backupFile); + console.log( + `[watchtower] migrated legacy JSON state to SQLite; backup=${backupFile}`, + ); + } + + this.db = new DatabaseSync(this.dbFile); + this.db.exec('PRAGMA journal_mode = WAL;'); + this.db.exec('PRAGMA synchronous = NORMAL;'); + this.db.exec('PRAGMA foreign_keys = ON;'); + + this.db.exec(` + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS closures ( + pipe_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + pipe_key_json TEXT NOT NULL, + closer TEXT, + expires_at TEXT, + nonce TEXT, + event TEXT NOT NULL, + txid TEXT, + block_height TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS observed_pipes ( + state_id TEXT PRIMARY KEY, + pipe_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + pipe_key_json TEXT NOT NULL, + balance_1 TEXT, + balance_2 TEXT, + pending_1_amount TEXT, + pending_1_burn_height TEXT, + pending_2_amount TEXT, + pending_2_burn_height TEXT, + expires_at TEXT, + nonce TEXT, + closer TEXT, + event TEXT NOT NULL, + txid TEXT, + block_height TEXT, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_observed_pipes_pipe + ON observed_pipes(contract_id, pipe_id); + + CREATE TABLE IF NOT EXISTS signature_states ( + state_id TEXT PRIMARY KEY, + pipe_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + for_principal TEXT NOT NULL, + with_principal TEXT NOT NULL, + token TEXT, + amount TEXT NOT NULL DEFAULT '0', + my_balance TEXT NOT NULL, + their_balance TEXT NOT NULL, + my_signature TEXT NOT NULL, + their_signature TEXT NOT NULL, + nonce TEXT NOT NULL, + action TEXT NOT NULL, + actor TEXT NOT NULL, + secret TEXT, + valid_after TEXT, + beneficial_only INTEGER NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_signature_states_contract_pipe + ON signature_states(contract_id, pipe_id); + + CREATE TABLE IF NOT EXISTS dispute_attempts ( + attempt_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + pipe_id TEXT NOT NULL, + for_principal TEXT NOT NULL, + trigger_txid TEXT, + success INTEGER NOT NULL, + dispute_txid TEXT, + error TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_dispute_attempts_created_at + ON dispute_attempts(created_at DESC); + + CREATE TABLE IF NOT EXISTS recent_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + event_json TEXT NOT NULL, + observed_at TEXT NOT NULL + ); + `); + + this.ensureObservedPipeColumns(); + this.ensureSignatureStateColumns(); + + const setMeta = this.db.prepare( + 'INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)', + ); + setMeta.run('version', '1'); + setMeta.run('updated_at', ''); + + if (legacyState) { + this.importLegacyState(legacyState); + } + } + + close(): void { + if (this.db) { + this.db.close(); + this.db = null; + } + } + + private getDb(): DatabaseSync { + if (!this.db) { + throw new Error('state store not loaded'); + } + return this.db; + } + + private ensureObservedPipeColumns(): void { + const db = this.getDb(); + const columns = db + .prepare('PRAGMA table_info(observed_pipes)') + .all() as Array<{ name: string }>; + const existing = new Set(columns.map((column) => column.name)); + + const required: Array<{ name: string; type: string }> = [ + { name: 'pending_1_amount', type: 'TEXT' }, + { name: 'pending_1_burn_height', type: 'TEXT' }, + { name: 'pending_2_amount', type: 'TEXT' }, + { name: 'pending_2_burn_height', type: 'TEXT' }, + ]; + + for (const column of required) { + if (existing.has(column.name)) { + continue; + } + + db.exec( + `ALTER TABLE observed_pipes ADD COLUMN ${column.name} ${column.type}`, + ); + } + } + + private ensureSignatureStateColumns(): void { + const db = this.getDb(); + const columns = db + .prepare('PRAGMA table_info(signature_states)') + .all() as Array<{ name: string }>; + const existing = new Set(columns.map((column) => column.name)); + + if (!existing.has('amount')) { + db.exec("ALTER TABLE signature_states ADD COLUMN amount TEXT NOT NULL DEFAULT '0'"); + } + } + + private loadLegacyJsonState(): WatchtowerPersistedState | null { + if (!fs.existsSync(this.dbFile)) { + return null; + } + + try { + const raw = fs.readFileSync(this.dbFile, 'utf8'); + const trimmed = raw.trimStart(); + if (!trimmed.startsWith('{')) { + return null; + } + + const parsed = JSON.parse(raw); + if (!isLegacyState(parsed)) { + return null; + } + + return parsed; + } catch { + return null; + } + } + + private importLegacyState(legacyState: WatchtowerPersistedState): void { + const db = this.getDb(); + db.exec('BEGIN'); + try { + const setMeta = db.prepare('UPDATE meta SET value = ? WHERE key = ?'); + setMeta.run(String(legacyState.version || 1), 'version'); + setMeta.run(legacyState.updatedAt || '', 'updated_at'); + + const insertClosure = db.prepare(` + INSERT OR REPLACE INTO closures ( + pipe_id, + contract_id, + pipe_key_json, + closer, + expires_at, + nonce, + event, + txid, + block_height, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const closure of Object.values(legacyState.activeClosures || {})) { + insertClosure.run( + closure.pipeId, + closure.contractId, + JSON.stringify(closure.pipeKey), + closure.closer, + closure.expiresAt, + closure.nonce, + closure.event, + closure.txid, + closure.blockHeight, + closure.updatedAt, + ); + } + + const insertObservedPipe = db.prepare(` + INSERT OR REPLACE INTO observed_pipes ( + state_id, + pipe_id, + contract_id, + pipe_key_json, + balance_1, + balance_2, + pending_1_amount, + pending_1_burn_height, + pending_2_amount, + pending_2_burn_height, + expires_at, + nonce, + closer, + event, + txid, + block_height, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const observedPipe of Object.values(legacyState.observedPipes || {})) { + insertObservedPipe.run( + observedPipe.stateId, + observedPipe.pipeId, + observedPipe.contractId, + JSON.stringify(observedPipe.pipeKey), + observedPipe.balance1, + observedPipe.balance2, + observedPipe.pending1Amount ?? null, + observedPipe.pending1BurnHeight ?? null, + observedPipe.pending2Amount ?? null, + observedPipe.pending2BurnHeight ?? null, + observedPipe.expiresAt, + observedPipe.nonce, + observedPipe.closer, + observedPipe.event, + observedPipe.txid, + observedPipe.blockHeight, + observedPipe.updatedAt, + ); + } + + const insertSignatureState = db.prepare(` + INSERT OR REPLACE INTO signature_states ( + state_id, + pipe_id, + contract_id, + for_principal, + with_principal, + token, + amount, + my_balance, + their_balance, + my_signature, + their_signature, + nonce, + action, + actor, + secret, + valid_after, + beneficial_only, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const state of Object.values(legacyState.signatureStates || {})) { + insertSignatureState.run( + state.stateId, + state.pipeId, + state.contractId, + state.forPrincipal, + state.withPrincipal, + state.token, + state.amount ?? '0', + state.myBalance, + state.theirBalance, + state.mySignature, + state.theirSignature, + state.nonce, + state.action, + state.actor, + state.secret, + state.validAfter, + state.beneficialOnly ? 1 : 0, + state.updatedAt, + ); + } + + const insertDisputeAttempt = db.prepare(` + INSERT OR REPLACE INTO dispute_attempts ( + attempt_id, + contract_id, + pipe_id, + for_principal, + trigger_txid, + success, + dispute_txid, + error, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + for (const attempt of Object.values(legacyState.disputeAttempts || {})) { + insertDisputeAttempt.run( + attempt.attemptId, + attempt.contractId, + attempt.pipeId, + attempt.forPrincipal, + attempt.triggerTxid, + attempt.success ? 1 : 0, + attempt.disputeTxid, + attempt.error, + attempt.createdAt, + ); + } + + const insertEvent = db.prepare( + 'INSERT INTO recent_events (event_json, observed_at) VALUES (?, ?)', + ); + for (const event of legacyState.recentEvents || []) { + insertEvent.run(JSON.stringify(event), event.observedAt); + } + + db.prepare(` + DELETE FROM recent_events + WHERE seq NOT IN ( + SELECT seq FROM recent_events + ORDER BY seq DESC + LIMIT ? + ) + `).run(this.maxRecentEvents); + + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } + + private touchUpdatedAt(): void { + const db = this.getDb(); + db.prepare('UPDATE meta SET value = ? WHERE key = ?').run( + new Date().toISOString(), + 'updated_at', + ); + } + + private getMeta(key: string): string | null { + const db = this.getDb(); + const row = db + .prepare('SELECT value FROM meta WHERE key = ?') + .get(key) as { value: string } | undefined; + + if (!row) { + return null; + } + + return row.value; + } + + private mapClosureRow(row: ClosureRow): ClosureRecord | null { + const pipeKey = parsePipeKey(row.pipe_key_json); + if (!pipeKey) { + return null; + } + + return { + pipeId: row.pipe_id, + contractId: row.contract_id, + pipeKey, + closer: row.closer, + expiresAt: row.expires_at, + nonce: row.nonce, + event: row.event, + txid: row.txid, + blockHeight: row.block_height, + updatedAt: row.updated_at, + }; + } + + private mapObservedPipeRow(row: ObservedPipeRow): ObservedPipeRecord | null { + const pipeKey = parsePipeKey(row.pipe_key_json); + if (!pipeKey) { + return null; + } + + return { + stateId: row.state_id, + pipeId: row.pipe_id, + contractId: row.contract_id, + pipeKey, + balance1: row.balance_1, + balance2: row.balance_2, + pending1Amount: row.pending_1_amount, + pending1BurnHeight: row.pending_1_burn_height, + pending2Amount: row.pending_2_amount, + pending2BurnHeight: row.pending_2_burn_height, + expiresAt: row.expires_at, + nonce: row.nonce, + closer: row.closer, + event: row.event, + txid: row.txid, + blockHeight: row.block_height, + updatedAt: row.updated_at, + }; + } + + private mapSignatureStateRow(row: SignatureStateRow): SignatureStateRecord { + return { + stateId: row.state_id, + pipeId: row.pipe_id, + contractId: row.contract_id, + forPrincipal: row.for_principal, + withPrincipal: row.with_principal, + token: row.token, + amount: row.amount ?? '0', + myBalance: row.my_balance, + theirBalance: row.their_balance, + mySignature: row.my_signature, + theirSignature: row.their_signature, + nonce: row.nonce, + action: row.action, + actor: row.actor, + secret: row.secret, + validAfter: row.valid_after, + beneficialOnly: row.beneficial_only === 1, + updatedAt: row.updated_at, + }; + } + + private mapDisputeAttemptRow(row: DisputeAttemptRow): DisputeAttemptRecord { + return { + attemptId: row.attempt_id, + contractId: row.contract_id, + pipeId: row.pipe_id, + forPrincipal: row.for_principal, + triggerTxid: row.trigger_txid, + success: row.success === 1, + disputeTxid: row.dispute_txid, + error: row.error, + createdAt: row.created_at, + }; + } + + getSnapshot(): WatchtowerPersistedState { + const db = this.getDb(); + + const closureRows = db + .prepare('SELECT * FROM closures') + .all() as unknown as ClosureRow[]; + const observedPipeRows = db + .prepare('SELECT * FROM observed_pipes') + .all() as unknown as ObservedPipeRow[]; + const signatureRows = db + .prepare('SELECT * FROM signature_states') + .all() as unknown as SignatureStateRow[]; + const disputeRows = db + .prepare('SELECT * FROM dispute_attempts') + .all() as unknown as DisputeAttemptRow[]; + const eventRows = db + .prepare('SELECT event_json FROM recent_events ORDER BY seq DESC') + .all() as Array<{ event_json: string }>; + + const activeClosures: Record = {}; + for (const row of closureRows) { + const mapped = this.mapClosureRow(row); + if (mapped) { + activeClosures[mapped.pipeId] = mapped; + } + } + + const observedPipes: Record = {}; + for (const row of observedPipeRows) { + const mapped = this.mapObservedPipeRow(row); + if (mapped) { + observedPipes[mapped.stateId] = mapped; + } + } + + const signatureStates: Record = {}; + for (const row of signatureRows) { + const mapped = this.mapSignatureStateRow(row); + signatureStates[mapped.stateId] = mapped; + } + + const disputeAttempts: Record = {}; + for (const row of disputeRows) { + const mapped = this.mapDisputeAttemptRow(row); + disputeAttempts[mapped.attemptId] = mapped; + } + + const recentEvents: RecordedWatchtowerEvent[] = []; + for (const row of eventRows) { + try { + const parsed = JSON.parse(row.event_json) as RecordedWatchtowerEvent; + recentEvents.push(parsed); + } catch { + // Skip corrupted rows to keep the store usable. + } + } + + return { + version: Number.parseInt(this.getMeta('version') || '1', 10) || 1, + updatedAt: this.getMeta('updated_at') || null, + activeClosures, + observedPipes, + signatureStates, + disputeAttempts, + recentEvents, + }; + } + + recordEvent(event: RecordedWatchtowerEvent): void { + const db = this.getDb(); + const insert = db.prepare( + 'INSERT INTO recent_events (event_json, observed_at) VALUES (?, ?)', + ); + const prune = db.prepare(` + DELETE FROM recent_events + WHERE seq NOT IN ( + SELECT seq FROM recent_events + ORDER BY seq DESC + LIMIT ? + ) + `); + + insert.run(JSON.stringify(event), event.observedAt); + prune.run(this.maxRecentEvents); + this.touchUpdatedAt(); + } + + setClosure(closure: ClosureRecord): void { + const db = this.getDb(); + db.prepare(` + INSERT INTO closures ( + pipe_id, + contract_id, + pipe_key_json, + closer, + expires_at, + nonce, + event, + txid, + block_height, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pipe_id) DO UPDATE SET + contract_id = excluded.contract_id, + pipe_key_json = excluded.pipe_key_json, + closer = excluded.closer, + expires_at = excluded.expires_at, + nonce = excluded.nonce, + event = excluded.event, + txid = excluded.txid, + block_height = excluded.block_height, + updated_at = excluded.updated_at + `).run( + closure.pipeId, + closure.contractId, + JSON.stringify(closure.pipeKey), + closure.closer, + closure.expiresAt, + closure.nonce, + closure.event, + closure.txid, + closure.blockHeight, + closure.updatedAt, + ); + this.touchUpdatedAt(); + } + + deleteClosure(pipeId: string): void { + const db = this.getDb(); + const result = db + .prepare('DELETE FROM closures WHERE pipe_id = ?') + .run(pipeId); + + if (result.changes > 0) { + this.touchUpdatedAt(); + } + } + + listClosures(): ClosureRecord[] { + const db = this.getDb(); + const rows = db + .prepare('SELECT * FROM closures') + .all() as unknown as ClosureRow[]; + + const closures: ClosureRecord[] = []; + for (const row of rows) { + const mapped = this.mapClosureRow(row); + if (mapped) { + closures.push(mapped); + } + } + + return closures; + } + + setObservedPipe(state: ObservedPipeRecord): void { + const db = this.getDb(); + db.prepare(` + INSERT INTO observed_pipes ( + state_id, + pipe_id, + contract_id, + pipe_key_json, + balance_1, + balance_2, + pending_1_amount, + pending_1_burn_height, + pending_2_amount, + pending_2_burn_height, + expires_at, + nonce, + closer, + event, + txid, + block_height, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(state_id) DO UPDATE SET + pipe_id = excluded.pipe_id, + contract_id = excluded.contract_id, + pipe_key_json = excluded.pipe_key_json, + balance_1 = excluded.balance_1, + balance_2 = excluded.balance_2, + pending_1_amount = excluded.pending_1_amount, + pending_1_burn_height = excluded.pending_1_burn_height, + pending_2_amount = excluded.pending_2_amount, + pending_2_burn_height = excluded.pending_2_burn_height, + expires_at = excluded.expires_at, + nonce = excluded.nonce, + closer = excluded.closer, + event = excluded.event, + txid = excluded.txid, + block_height = excluded.block_height, + updated_at = excluded.updated_at + `).run( + state.stateId, + state.pipeId, + state.contractId, + JSON.stringify(state.pipeKey), + state.balance1, + state.balance2, + state.pending1Amount, + state.pending1BurnHeight, + state.pending2Amount, + state.pending2BurnHeight, + state.expiresAt, + state.nonce, + state.closer, + state.event, + state.txid, + state.blockHeight, + state.updatedAt, + ); + this.touchUpdatedAt(); + } + + deleteObservedPipe(stateId: string): void { + const db = this.getDb(); + const result = db + .prepare('DELETE FROM observed_pipes WHERE state_id = ?') + .run(stateId); + + if (result.changes > 0) { + this.touchUpdatedAt(); + } + } + + listObservedPipes(): ObservedPipeRecord[] { + const db = this.getDb(); + const rows = db + .prepare('SELECT * FROM observed_pipes') + .all() as unknown as ObservedPipeRow[]; + + const observedPipes: ObservedPipeRecord[] = []; + for (const row of rows) { + const mapped = this.mapObservedPipeRow(row); + if (mapped) { + observedPipes.push(mapped); + } + } + + return observedPipes; + } + + getSignatureStates(): SignatureStateRecord[] { + const db = this.getDb(); + const rows = db + .prepare('SELECT * FROM signature_states') + .all() as unknown as SignatureStateRow[]; + return rows.map((row) => this.mapSignatureStateRow(row)); + } + + getSignatureStatesForPipe( + contractId: string, + pipeId: string, + ): SignatureStateRecord[] { + const db = this.getDb(); + const rows = db + .prepare(` + SELECT * FROM signature_states + WHERE contract_id = ? AND pipe_id = ? + `) + .all(contractId, pipeId) as unknown as SignatureStateRow[]; + return rows.map((row) => this.mapSignatureStateRow(row)); + } + + setSignatureState(state: SignatureStateRecord): void { + const db = this.getDb(); + db.prepare(` + INSERT INTO signature_states ( + state_id, + pipe_id, + contract_id, + for_principal, + with_principal, + token, + amount, + my_balance, + their_balance, + my_signature, + their_signature, + nonce, + action, + actor, + secret, + valid_after, + beneficial_only, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(state_id) DO UPDATE SET + pipe_id = excluded.pipe_id, + contract_id = excluded.contract_id, + for_principal = excluded.for_principal, + with_principal = excluded.with_principal, + token = excluded.token, + amount = excluded.amount, + my_balance = excluded.my_balance, + their_balance = excluded.their_balance, + my_signature = excluded.my_signature, + their_signature = excluded.their_signature, + nonce = excluded.nonce, + action = excluded.action, + actor = excluded.actor, + secret = excluded.secret, + valid_after = excluded.valid_after, + beneficial_only = excluded.beneficial_only, + updated_at = excluded.updated_at + `).run( + state.stateId, + state.pipeId, + state.contractId, + state.forPrincipal, + state.withPrincipal, + state.token, + state.amount, + state.myBalance, + state.theirBalance, + state.mySignature, + state.theirSignature, + state.nonce, + state.action, + state.actor, + state.secret, + state.validAfter, + state.beneficialOnly ? 1 : 0, + state.updatedAt, + ); + this.touchUpdatedAt(); + } + + getDisputeAttempt(attemptId: string): DisputeAttemptRecord | null { + const db = this.getDb(); + const row = db + .prepare('SELECT * FROM dispute_attempts WHERE attempt_id = ?') + .get(attemptId) as DisputeAttemptRow | undefined; + if (!row) { + return null; + } + return this.mapDisputeAttemptRow(row); + } + + setDisputeAttempt(attempt: DisputeAttemptRecord): void { + const db = this.getDb(); + db.prepare(` + INSERT INTO dispute_attempts ( + attempt_id, + contract_id, + pipe_id, + for_principal, + trigger_txid, + success, + dispute_txid, + error, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(attempt_id) DO UPDATE SET + contract_id = excluded.contract_id, + pipe_id = excluded.pipe_id, + for_principal = excluded.for_principal, + trigger_txid = excluded.trigger_txid, + success = excluded.success, + dispute_txid = excluded.dispute_txid, + error = excluded.error, + created_at = excluded.created_at + `).run( + attempt.attemptId, + attempt.contractId, + attempt.pipeId, + attempt.forPrincipal, + attempt.triggerTxid, + attempt.success ? 1 : 0, + attempt.disputeTxid, + attempt.error, + attempt.createdAt, + ); + this.touchUpdatedAt(); + } + + listDisputeAttempts(): DisputeAttemptRecord[] { + const db = this.getDb(); + const rows = db + .prepare('SELECT * FROM dispute_attempts ORDER BY created_at DESC') + .all() as unknown as DisputeAttemptRow[]; + return rows.map((row) => this.mapDisputeAttemptRow(row)); + } +} diff --git a/server/src/types.ts b/server/src/types.ts new file mode 100644 index 0000000..1b0aee2 --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,214 @@ +export interface PipeKey { + 'principal-1': string; + 'principal-2': string; + token: string | null; +} + +export interface PipePendingSnapshot { + amount: string | null; + 'burn-height': string | null; +} + +export interface PipeSnapshot { + 'balance-1': string | null; + 'balance-2': string | null; + 'pending-1': PipePendingSnapshot | null; + 'pending-2': PipePendingSnapshot | null; + 'expires-at': string | null; + nonce: string | null; + closer: string | null; +} + +export interface StackflowPrintEvent { + contractId: string; + topic: 'print'; + txid: string | null; + blockHeight: string | null; + blockHash: string | null; + eventIndex: string | null; + eventName: string | null; + sender: string | null; + pipeKey: PipeKey | null; + pipe: PipeSnapshot | null; + repr: string | null; +} + +export interface ClosureRecord { + pipeId: string; + contractId: string; + pipeKey: PipeKey; + closer: string | null; + expiresAt: string | null; + nonce: string | null; + event: string; + txid: string | null; + blockHeight: string | null; + updatedAt: string; +} + +export interface ObservedPipeRecord { + stateId: string; + pipeId: string; + contractId: string; + pipeKey: PipeKey; + balance1: string | null; + balance2: string | null; + pending1Amount: string | null; + pending1BurnHeight: string | null; + pending2Amount: string | null; + pending2BurnHeight: string | null; + expiresAt: string | null; + nonce: string | null; + closer: string | null; + event: string; + txid: string | null; + blockHeight: string | null; + updatedAt: string; +} + +export interface SignatureStateRecord { + stateId: string; + pipeId: string; + contractId: string; + forPrincipal: string; + withPrincipal: string; + token: string | null; + amount: string; + myBalance: string; + theirBalance: string; + mySignature: string; + theirSignature: string; + nonce: string; + action: string; + actor: string; + secret: string | null; + validAfter: string | null; + beneficialOnly: boolean; + updatedAt: string; +} + +export interface SignatureStateInput { + contractId: string; + forPrincipal: string; + withPrincipal: string; + token: string | null; + amount: string; + myBalance: string; + theirBalance: string; + mySignature: string; + theirSignature: string; + nonce: string; + action: string; + actor: string; + secret: string | null; + validAfter: string | null; + beneficialOnly: boolean; +} + +export interface SignatureStateUpsertResult { + stored: boolean; + replaced: boolean; + reason: string | null; + state: SignatureStateRecord; +} + +export interface SignatureVerificationResult { + valid: boolean; + reason: string | null; +} + +export interface SignatureVerifier { + verifySignatureState( + input: SignatureStateInput, + ): Promise; +} + +export interface DisputeAttemptRecord { + attemptId: string; + contractId: string; + pipeId: string; + forPrincipal: string; + triggerTxid: string | null; + success: boolean; + disputeTxid: string | null; + error: string | null; + createdAt: string; +} + +export interface WatchtowerPersistedState { + version: number; + updatedAt: string | null; + activeClosures: Record; + observedPipes: Record; + signatureStates: Record; + disputeAttempts: Record; + recentEvents: RecordedWatchtowerEvent[]; +} + +export interface RecordedWatchtowerEvent extends StackflowPrintEvent { + source: string | null; + observedAt: string; +} + +export interface WatchtowerStatus { + version: number; + updatedAt: string | null; + activeClosures: ClosureRecord[]; + observedPipes: ObservedPipeRecord[]; + signatureStates: SignatureStateRecord[]; + disputeAttempts: DisputeAttemptRecord[]; + recentEvents: RecordedWatchtowerEvent[]; +} + +export interface IngestResult { + observedEvents: number; + activeClosures: number; +} + +export type SignatureVerifierMode = + | 'readonly' + | 'accept-all' + | 'reject-all'; + +export type DisputeExecutorMode = + | 'auto' + | 'noop' + | 'mock'; + +export type ProducerSignerMode = + | 'local-key' + | 'kms'; + +export interface WatchtowerConfig { + host: string; + port: number; + dbFile: string; + maxRecentEvents: number; + logRawEvents: boolean; + watchedContracts: string[]; + watchedPrincipals: string[]; + stacksNetwork: 'mainnet' | 'testnet' | 'devnet' | 'mocknet'; + stacksApiUrl: string | null; + signerKey: string | null; + producerKey: string | null; + producerPrincipal: string | null; + producerSignerMode: ProducerSignerMode; + stackflowMessageVersion: string; + signatureVerifierMode: SignatureVerifierMode; + disputeExecutorMode: DisputeExecutorMode; + disputeOnlyBeneficial: boolean; +} + +export interface SubmitDisputeResult { + txid: string; +} + +export interface DisputeExecutor { + readonly enabled: boolean; + readonly signerAddress: string | null; + submitDispute(args: { + signatureState: SignatureStateRecord; + closure: ClosureRecord; + triggerEvent: StackflowPrintEvent; + }): Promise; +} diff --git a/server/src/watchtower.ts b/server/src/watchtower.ts new file mode 100644 index 0000000..07c459b --- /dev/null +++ b/server/src/watchtower.ts @@ -0,0 +1,804 @@ +import { + extractStackflowPrintEvents, + normalizePipeId, +} from './observer-parser.js'; +import { + canonicalPipeKey, + isValidHex, + parseOptionalUInt, + parsePrincipal, + parseUInt, + splitContractId, +} from './principal-utils.js'; +import { SqliteStateStore } from './state-store.js'; +import type { + ClosureRecord, + DisputeAttemptRecord, + DisputeExecutor, + IngestResult, + ObservedPipeRecord, + PipeKey, + RecordedWatchtowerEvent, + SignatureStateInput, + SignatureStateRecord, + SignatureStateUpsertResult, + SignatureVerifier, + StackflowPrintEvent, + WatchtowerStatus, +} from './types.js'; + +interface WatchtowerOptions { + stateStore: SqliteStateStore; + watchedContracts?: string[]; + watchedPrincipals?: string[]; + disputeExecutor?: DisputeExecutor; + disputeOnlyBeneficial?: boolean; + signatureVerifier?: SignatureVerifier; +} + +interface UpsertSignatureStateOptions { + skipVerification?: boolean; +} + +const OPEN_CLOSURE_EVENTS = new Set(['force-cancel', 'force-close']); +const TERMINAL_EVENTS = new Set(['close-pipe', 'dispute-closure', 'finalize']); +const ACTION_DEPOSIT = '2'; +const ACTION_WITHDRAWAL = '3'; + +function toBigInt(value: string | null): bigint | null { + if (value === null) { + return null; + } + return BigInt(value); +} + +function expiryValue(value: string | null): number { + if (!value) { + return Number.MAX_SAFE_INTEGER; + } + + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +} + +function sortClosures(closures: ClosureRecord[]): ClosureRecord[] { + return [...closures].sort((left, right) => { + const leftExpiry = expiryValue(left.expiresAt); + const rightExpiry = expiryValue(right.expiresAt); + + if (leftExpiry === rightExpiry) { + return left.pipeId.localeCompare(right.pipeId); + } + + return leftExpiry - rightExpiry; + }); +} + +function sortSignatureStates(states: SignatureStateRecord[]): SignatureStateRecord[] { + return [...states].sort((left, right) => { + const leftNonce = BigInt(left.nonce); + const rightNonce = BigInt(right.nonce); + + if (leftNonce === rightNonce) { + return right.updatedAt.localeCompare(left.updatedAt); + } + + return rightNonce > leftNonce ? 1 : -1; + }); +} + +function sortObservedPipes(states: ObservedPipeRecord[]): ObservedPipeRecord[] { + return [...states].sort((left, right) => { + const leftNonce = toBigInt(left.nonce) ?? -1n; + const rightNonce = toBigInt(right.nonce) ?? -1n; + if (leftNonce === rightNonce) { + return right.updatedAt.localeCompare(left.updatedAt); + } + return rightNonce > leftNonce ? 1 : -1; + }); +} + +function observedPipeStateId(contractId: string, pipeId: string): string { + return `${contractId}|${pipeId}`; +} + +function normalizeContractId(input: unknown): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new Error('contractId must be a non-empty string'); + } + + const contractId = input.trim(); + splitContractId(contractId); + return contractId; +} + +function normalizeToken(input: unknown): string | null { + if (input === null || input === undefined || input === '') { + return null; + } + + return parsePrincipal(input, 'token'); +} + +function normalizeHexBuff(input: unknown, bytes: number, fieldName: string): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new Error(`${fieldName} must be a hex string`); + } + + const value = input.trim().toLowerCase(); + if (!isValidHex(value, bytes)) { + throw new Error(`${fieldName} must be ${bytes} bytes of hex`); + } + + return value.startsWith('0x') ? value : `0x${value}`; +} + +function normalizeOptionalHexBuff( + input: unknown, + bytes: number, + fieldName: string, +): string | null { + if (input === null || input === undefined || input === '') { + return null; + } + + return normalizeHexBuff(input, bytes, fieldName); +} + +function normalizeBool(input: unknown, fallback: boolean): boolean { + if (input === undefined || input === null || input === '') { + return fallback; + } + + if (typeof input === 'boolean') { + return input; + } + + const normalized = String(input).trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + + throw new Error('beneficialOnly must be a boolean'); +} + +function parseSignatureStateInput( + input: unknown, + defaultBeneficialOnly: boolean, +): SignatureStateInput { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new Error('signature state payload must be an object'); + } + + const data = input as Record; + const contractId = normalizeContractId(data.contractId); + const forPrincipal = parsePrincipal(data.forPrincipal, 'forPrincipal'); + const withPrincipal = parsePrincipal(data.withPrincipal, 'withPrincipal'); + const token = normalizeToken(data.token); + const action = parseUInt(data.action); + const amount = + action === ACTION_DEPOSIT || action === ACTION_WITHDRAWAL + ? parseUInt(data.amount) + : parseOptionalUInt(data.amount) || '0'; + + return { + contractId, + forPrincipal, + withPrincipal, + token, + amount, + myBalance: parseUInt(data.myBalance), + theirBalance: parseUInt(data.theirBalance), + mySignature: normalizeHexBuff(data.mySignature, 65, 'mySignature'), + theirSignature: normalizeHexBuff(data.theirSignature, 65, 'theirSignature'), + nonce: parseUInt(data.nonce), + action, + actor: parsePrincipal(data.actor, 'actor'), + secret: normalizeOptionalHexBuff(data.secret, 32, 'secret'), + validAfter: parseOptionalUInt(data.validAfter), + beneficialOnly: normalizeBool(data.beneficialOnly, defaultBeneficialOnly), + }; +} + +function getClosureSideBalance( + event: StackflowPrintEvent, + forPrincipal: string, +): string | null { + if (!event.pipeKey || !event.pipe) { + return null; + } + + if (event.pipeKey['principal-1'] === forPrincipal) { + return event.pipe['balance-1']; + } + + if (event.pipeKey['principal-2'] === forPrincipal) { + return event.pipe['balance-2']; + } + + return null; +} + +function parseUnsignedBigInt(value: string | null): bigint | null { + if (value === null || !/^\d+$/.test(value)) { + return null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + +export class Watchtower { + private readonly stateStore: SqliteStateStore; + + private readonly watchedContracts: string[]; + + private readonly watchedPrincipals: Set; + + private readonly disputeExecutor: DisputeExecutor | null; + + private readonly disputeOnlyBeneficial: boolean; + + private readonly signatureVerifier: SignatureVerifier | null; + + constructor({ + stateStore, + watchedContracts = [], + watchedPrincipals = [], + disputeExecutor, + disputeOnlyBeneficial = false, + signatureVerifier, + }: WatchtowerOptions) { + this.stateStore = stateStore; + this.watchedContracts = watchedContracts; + this.watchedPrincipals = new Set(watchedPrincipals); + this.disputeExecutor = disputeExecutor || null; + this.disputeOnlyBeneficial = disputeOnlyBeneficial; + this.signatureVerifier = signatureVerifier || null; + } + + async upsertSignatureState( + input: unknown, + options: UpsertSignatureStateOptions = {}, + ): Promise { + const normalized = parseSignatureStateInput(input, this.disputeOnlyBeneficial); + const context = `contract=${normalized.contractId} for=${normalized.forPrincipal} with=${normalized.withPrincipal} nonce=${normalized.nonce} action=${normalized.action} amount=${normalized.amount} token=${normalized.token ?? 'stx'}`; + + if (!this.isWatchedPrincipal(normalized.forPrincipal)) { + console.warn( + `[watchtower] signature-state processed result=rejected reason=principal-not-watched ${context}`, + ); + throw new PrincipalNotWatchedError(normalized.forPrincipal); + } + + if (!options.skipVerification && this.signatureVerifier) { + const verification = await this.signatureVerifier.verifySignatureState( + normalized, + ); + + if (!verification.valid) { + console.warn( + `[watchtower] signature-state processed result=rejected reason=${ + verification.reason || 'invalid-signature' + } ${context}`, + ); + throw new SignatureValidationError( + verification.reason || 'invalid signature', + ); + } + } + + const pipeKey = canonicalPipeKey( + normalized.token, + normalized.forPrincipal, + normalized.withPrincipal, + ); + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + throw new Error('failed to build pipe id'); + } + const pipeContext = `${context} pipeId=${pipeId}`; + + const stateId = `${normalized.contractId}|${pipeId}|${normalized.forPrincipal}`; + + const existing = this.stateStore + .getSignatureStates() + .find((state) => state.stateId === stateId); + + const nextState: SignatureStateRecord = { + stateId, + pipeId, + ...normalized, + updatedAt: new Date().toISOString(), + }; + + if (existing) { + const existingNonce = BigInt(existing.nonce); + const incomingNonce = BigInt(nextState.nonce); + + if (incomingNonce <= existingNonce) { + console.log( + `[watchtower] signature-state processed result=ignored reason=nonce-not-higher incomingNonce=${incomingNonce.toString( + 10, + )} existingNonce=${existingNonce.toString(10)} ${pipeContext}`, + ); + return { + stored: false, + replaced: false, + reason: 'nonce-too-low', + state: existing, + }; + } + + this.stateStore.setSignatureState(nextState); + console.log( + `[watchtower] signature-state processed result=stored replaced=true ${pipeContext}`, + ); + return { + stored: true, + replaced: true, + reason: null, + state: nextState, + }; + } + + this.stateStore.setSignatureState(nextState); + console.log( + `[watchtower] signature-state processed result=stored replaced=false ${pipeContext}`, + ); + return { + stored: true, + replaced: false, + reason: null, + state: nextState, + }; + } + + async ingest(payload: unknown, source: string | null = null): Promise { + const events = extractStackflowPrintEvents(payload, { + watchedContracts: this.watchedContracts, + }); + + console.log( + `[watchtower] stackflow events extracted=${events.length} source=${source ?? 'unknown'}`, + ); + + let observedEvents = 0; + for (const event of events) { + const pipeId = event.pipeKey ? normalizePipeId(event.pipeKey) : null; + const watchedPipe = this.isWatchedPipe(event.pipeKey); + console.log( + `[watchtower] stackflow event detected contract=${event.contractId} event=${ + event.eventName ?? 'unknown' + } txid=${event.txid ?? '-'} pipeId=${pipeId ?? '-'} watchedPipe=${watchedPipe}`, + ); + + if (!watchedPipe) { + continue; + } + + observedEvents += 1; + await this.handleEvent(event, source); + } + + return { + observedEvents, + activeClosures: this.stateStore.listClosures().length, + }; + } + + async ingestBurnBlock( + burnBlockHeightInput: string | number | bigint, + source: string | null = null, + ): Promise<{ + burnBlockHeight: string; + processedPipes: number; + settledPipes: number; + }> { + const burnBlockHeight = (() => { + if (typeof burnBlockHeightInput === 'bigint') { + return burnBlockHeightInput; + } + + if ( + typeof burnBlockHeightInput === 'number' && + Number.isFinite(burnBlockHeightInput) && + burnBlockHeightInput >= 0 + ) { + return BigInt(Math.trunc(burnBlockHeightInput)); + } + + if ( + typeof burnBlockHeightInput === 'string' && + /^\d+$/.test(burnBlockHeightInput) + ) { + return BigInt(burnBlockHeightInput); + } + + throw new Error('invalid burn block height'); + })(); + + let settledPipes = 0; + const observedPipes = this.stateStore.listObservedPipes(); + + for (const observedPipe of observedPipes) { + const currentBalance1 = parseUnsignedBigInt(observedPipe.balance1); + const currentBalance2 = parseUnsignedBigInt(observedPipe.balance2); + const pending1Amount = parseUnsignedBigInt(observedPipe.pending1Amount); + const pending1Height = parseUnsignedBigInt(observedPipe.pending1BurnHeight); + const pending2Amount = parseUnsignedBigInt(observedPipe.pending2Amount); + const pending2Height = parseUnsignedBigInt(observedPipe.pending2BurnHeight); + + let nextBalance1 = currentBalance1; + let nextBalance2 = currentBalance2; + let nextPending1Amount = observedPipe.pending1Amount; + let nextPending1Height = observedPipe.pending1BurnHeight; + let nextPending2Amount = observedPipe.pending2Amount; + let nextPending2Height = observedPipe.pending2BurnHeight; + + let changed = false; + + if ( + pending1Amount !== null && + pending1Height !== null && + burnBlockHeight >= pending1Height && + nextBalance1 !== null + ) { + nextBalance1 += pending1Amount; + nextPending1Amount = null; + nextPending1Height = null; + changed = true; + } + + if ( + pending2Amount !== null && + pending2Height !== null && + burnBlockHeight >= pending2Height && + nextBalance2 !== null + ) { + nextBalance2 += pending2Amount; + nextPending2Amount = null; + nextPending2Height = null; + changed = true; + } + + if (!changed) { + continue; + } + + const nextPipe: ObservedPipeRecord = { + ...observedPipe, + balance1: nextBalance1 ? nextBalance1.toString(10) : '0', + balance2: nextBalance2 ? nextBalance2.toString(10) : '0', + pending1Amount: nextPending1Amount, + pending1BurnHeight: nextPending1Height, + pending2Amount: nextPending2Amount, + pending2BurnHeight: nextPending2Height, + updatedAt: new Date().toISOString(), + }; + + settledPipes += 1; + this.stateStore.setObservedPipe(nextPipe); + + console.log( + `[watchtower] pending settled pipeId=${observedPipe.pipeId} burnBlock=${burnBlockHeight.toString( + 10, + )} balance1=${nextPipe.balance1 ?? '-'} balance2=${nextPipe.balance2 ?? '-'}`, + ); + } + + console.log( + `[watchtower] burn block processed height=${burnBlockHeight.toString( + 10, + )} source=${source ?? 'unknown'} settledPipes=${settledPipes}`, + ); + + return { + burnBlockHeight: burnBlockHeight.toString(10), + processedPipes: observedPipes.length, + settledPipes, + }; + } + + private isWatchedPrincipal(principal: string): boolean { + if (this.watchedPrincipals.size === 0) { + return true; + } + + return this.watchedPrincipals.has(principal); + } + + private isWatchedPipe(pipeKey: PipeKey | null): boolean { + if (this.watchedPrincipals.size === 0) { + return true; + } + + if (!pipeKey) { + return false; + } + + return ( + this.watchedPrincipals.has(pipeKey['principal-1']) || + this.watchedPrincipals.has(pipeKey['principal-2']) + ); + } + + private async handleEvent( + event: StackflowPrintEvent, + source: string | null = null, + ): Promise { + const processedEvent: RecordedWatchtowerEvent = { + ...event, + source, + observedAt: new Date().toISOString(), + }; + + this.stateStore.recordEvent(processedEvent); + console.log( + `[watchtower] event recorded event=${event.eventName ?? 'unknown'} txid=${event.txid ?? '-'} source=${ + source ?? 'unknown' + }`, + ); + + if (!event.pipeKey || !event.eventName) { + console.log('[watchtower] event skipped reason=missing-pipe-or-event-name'); + return; + } + + const pipeId = normalizePipeId(event.pipeKey); + if (!pipeId) { + console.log('[watchtower] event skipped reason=invalid-pipe-id'); + return; + } + + const stateId = observedPipeStateId(event.contractId, pipeId); + + if (event.pipe && !TERMINAL_EVENTS.has(event.eventName)) { + const observedPipe: ObservedPipeRecord = { + stateId, + pipeId, + contractId: event.contractId, + pipeKey: event.pipeKey, + balance1: event.pipe['balance-1'], + balance2: event.pipe['balance-2'], + pending1Amount: event.pipe['pending-1']?.amount ?? null, + pending1BurnHeight: event.pipe['pending-1']?.['burn-height'] ?? null, + pending2Amount: event.pipe['pending-2']?.amount ?? null, + pending2BurnHeight: event.pipe['pending-2']?.['burn-height'] ?? null, + expiresAt: event.pipe['expires-at'], + nonce: event.pipe.nonce, + closer: event.pipe.closer, + event: event.eventName, + txid: event.txid, + blockHeight: event.blockHeight, + updatedAt: new Date().toISOString(), + }; + this.stateStore.setObservedPipe(observedPipe); + console.log( + `[watchtower] observed pipe updated pipeId=${pipeId} event=${event.eventName} nonce=${ + observedPipe.nonce ?? '-' + }`, + ); + } + + if (OPEN_CLOSURE_EVENTS.has(event.eventName)) { + const closer = event.pipe?.closer || event.sender || null; + + const closure: ClosureRecord = { + pipeId, + contractId: event.contractId, + pipeKey: event.pipeKey, + closer, + expiresAt: event.pipe ? event.pipe['expires-at'] : null, + nonce: event.pipe ? event.pipe.nonce : null, + event: event.eventName, + txid: event.txid, + blockHeight: event.blockHeight, + updatedAt: new Date().toISOString(), + }; + + this.stateStore.setClosure(closure); + console.log( + `[watchtower] closure opened pipeId=${pipeId} event=${event.eventName} nonce=${ + closure.nonce ?? '-' + } expiresAt=${closure.expiresAt ?? '-'}`, + ); + await this.tryDisputeClosure(event, closure); + return; + } + + if (TERMINAL_EVENTS.has(event.eventName)) { + const observedPipe: ObservedPipeRecord = { + stateId, + pipeId, + contractId: event.contractId, + pipeKey: event.pipeKey, + balance1: '0', + balance2: '0', + pending1Amount: null, + pending1BurnHeight: null, + pending2Amount: null, + pending2BurnHeight: null, + expiresAt: event.pipe?.['expires-at'] ?? null, + nonce: event.pipe?.nonce ?? null, + closer: null, + event: event.eventName, + txid: event.txid, + blockHeight: event.blockHeight, + updatedAt: new Date().toISOString(), + }; + this.stateStore.setObservedPipe(observedPipe); + this.stateStore.deleteClosure(pipeId); + console.log( + `[watchtower] terminal event settled pipeId=${pipeId} event=${event.eventName} balances-reset-to-zero`, + ); + return; + } + } + + private async tryDisputeClosure( + triggerEvent: StackflowPrintEvent, + closure: ClosureRecord, + ): Promise { + if (!this.disputeExecutor?.enabled) { + console.log( + `[watchtower] dispute skipped reason=executor-disabled pipeId=${closure.pipeId} contract=${closure.contractId}`, + ); + return; + } + + const closureNonce = toBigInt(closure.nonce); + if (closureNonce === null) { + console.log( + `[watchtower] dispute skipped reason=missing-closure-nonce pipeId=${closure.pipeId} contract=${closure.contractId}`, + ); + return; + } + + const closer = closure.closer; + if (!closer) { + console.log( + `[watchtower] dispute skipped reason=missing-closer pipeId=${closure.pipeId} contract=${closure.contractId}`, + ); + return; + } + + const candidates = sortSignatureStates( + this.stateStore.getSignatureStatesForPipe(closure.contractId, closure.pipeId), + ).filter((state) => state.forPrincipal !== closer); + + if (candidates.length === 0) { + console.log( + `[watchtower] dispute skipped reason=no-counterparty-state pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer}`, + ); + return; + } + + console.log( + `[watchtower] dispute evaluate pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer} closureNonce=${closureNonce.toString( + 10, + )} candidateStates=${candidates.length}`, + ); + + const eventHeight = toBigInt(triggerEvent.blockHeight); + + const eligible = candidates.find((state) => { + if (BigInt(state.nonce) <= closureNonce) { + return false; + } + + if (state.validAfter !== null && eventHeight !== null && BigInt(state.validAfter) > eventHeight) { + return false; + } + + const useBeneficialPolicy = this.disputeOnlyBeneficial || state.beneficialOnly; + if (!useBeneficialPolicy) { + return true; + } + + const closureBalance = getClosureSideBalance(triggerEvent, state.forPrincipal); + if (closureBalance === null) { + return false; + } + + return BigInt(state.myBalance) > BigInt(closureBalance); + }); + + if (!eligible) { + console.log( + `[watchtower] dispute skipped reason=no-eligible-state pipeId=${closure.pipeId} contract=${closure.contractId}`, + ); + return; + } + + const attemptId = `${triggerEvent.txid || `${closure.contractId}|${closure.pipeId}|${closure.nonce}`}|${eligible.forPrincipal}`; + + const existingAttempt = this.stateStore.getDisputeAttempt(attemptId); + if (existingAttempt?.success) { + console.log( + `[watchtower] dispute skipped reason=already-submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} attemptId=${attemptId}`, + ); + return; + } + + console.log( + `[watchtower] dispute submit pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} nonce=${eligible.nonce} triggerTxid=${triggerEvent.txid ?? '-'} mode=${ + this.disputeExecutor.constructor.name + }`, + ); + + try { + const result = await this.disputeExecutor.submitDispute({ + signatureState: eligible, + closure, + triggerEvent, + }); + + const attempt: DisputeAttemptRecord = { + attemptId, + contractId: closure.contractId, + pipeId: closure.pipeId, + forPrincipal: eligible.forPrincipal, + triggerTxid: triggerEvent.txid, + success: true, + disputeTxid: result.txid, + error: null, + createdAt: new Date().toISOString(), + }; + this.stateStore.setDisputeAttempt(attempt); + console.log( + `[watchtower] dispute submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} disputeTxid=${result.txid}`, + ); + } catch (error) { + const attempt: DisputeAttemptRecord = { + attemptId, + contractId: closure.contractId, + pipeId: closure.pipeId, + forPrincipal: eligible.forPrincipal, + triggerTxid: triggerEvent.txid, + success: false, + disputeTxid: null, + error: error instanceof Error ? error.message : 'dispute submission failed', + createdAt: new Date().toISOString(), + }; + this.stateStore.setDisputeAttempt(attempt); + console.error( + `[watchtower] dispute failed pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} error=${attempt.error}`, + ); + } + } + + status(): WatchtowerStatus { + const snapshot = this.stateStore.getSnapshot(); + + return { + version: snapshot.version, + updatedAt: snapshot.updatedAt, + activeClosures: sortClosures(Object.values(snapshot.activeClosures)), + observedPipes: sortObservedPipes(Object.values(snapshot.observedPipes)), + signatureStates: sortSignatureStates(Object.values(snapshot.signatureStates)), + disputeAttempts: this.stateStore.listDisputeAttempts(), + recentEvents: snapshot.recentEvents, + }; + } +} + +export class SignatureValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'SignatureValidationError'; + } +} + +export class PrincipalNotWatchedError extends Error { + constructor(principal: string) { + super(`principal is not watched: ${principal}`); + this.name = 'PrincipalNotWatchedError'; + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..5d81085 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.server.json", + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/server/ui/index.html b/server/ui/index.html new file mode 100644 index 0000000..65bf1a2 --- /dev/null +++ b/server/ui/index.html @@ -0,0 +1,161 @@ + + + + + + Stackflow Console + + + + + + +
+
+

Stackflow Console

+

Connect wallet, inspect watched pipes, generate signatures, and call Stackflow contract functions.

+
+ +
+

Connection

+
+ + + + +
+
+ + + +
+

Wallet not connected.

+
+ +
+

Watched Pipes

+
+
+

Connect wallet to load watched pipes.

+
+
+
+ +
+

Action

+

Select one action. The form below will show only the relevant inputs.

+
+ + + + + + + + + + + + + + + +
+
+ +
+

+

+        

+
+
+ + + + diff --git a/server/ui/main.js b/server/ui/main.js new file mode 100644 index 0000000..cbbe7b7 --- /dev/null +++ b/server/ui/main.js @@ -0,0 +1,1603 @@ +// server/ui/main.src.js +import { connect, disconnect, isConnected, request } from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +import { + Cl, + Pc, + principalCV, + serializeCV +} from "https://esm.sh/@stacks/transactions@7.2.0?bundle&target=es2020"; +var CHAIN_IDS = { + mainnet: 1n, + testnet: 2147483648n, + devnet: 2147483648n, + mocknet: 2147483648n +}; +var STORAGE_KEY = "stackflow-console-config-v1"; +var connectedAddress = null; +var watchtowerProducerEnabled = false; +var watchtowerProducerPrincipal = null; +var ids = { + watchtowerUrl: "watchtower-url", + contractId: "contract-id", + network: "network", + contractVersion: "contract-version", + walletStatus: "wallet-status", + pipesBody: "pipes-body", + sigWith: "sig-with", + sigActor: "sig-actor", + sigToken: "sig-token", + sigTokenAssetName: "sig-token-asset-name", + sigAction: "sig-action", + sigMyBalance: "sig-my-balance", + sigTheirBalance: "sig-their-balance", + sigNonce: "sig-nonce", + sigValidAfter: "sig-valid-after", + sigSecret: "sig-secret", + sigMySignature: "sig-my-signature", + sigTheirSignature: "sig-their-signature", + signaturePayload: "signature-payload", + txResult: "tx-result", + actionHelp: "action-help", + actionSelect: "action-select", + actionSubmitBtn: "action-submit-btn", + callFundAmount: "call-fund-amount", + callAmountLabel: "call-amount-label", + sigMySignatureLabel: "sig-my-signature-label", + sigMySignatureHelp: "sig-my-signature-help" +}; +var ACTION_FIELD_IDS = [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-nonce", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-action", + "field-sig-actor", + "field-sig-valid-after", + "field-sig-secret", + "field-sig-my-signature", + "field-sig-their-signature" +]; +var ACTION_DEFS = { + "fund-pipe": { + submitLabel: "Submit fund-pipe", + help: "Create or add initial liquidity to a pipe on-chain.", + amountLabel: "fund-pipe Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-nonce" + ] + }, + deposit: { + submitLabel: "Submit deposit", + help: "Add funds on-chain using signatures from both parties.", + amountLabel: "deposit Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + withdraw: { + submitLabel: "Submit withdraw", + help: "Withdraw funds on-chain using signatures from both parties.", + amountLabel: "withdraw Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "force-cancel": { + submitLabel: "Submit force-cancel", + help: "Start an on-chain cancellation waiting period for this pipe.", + fields: ["field-sig-with", "field-sig-token"] + }, + "close-pipe": { + submitLabel: "Submit close-pipe", + help: "Cooperatively close a pipe with both signatures.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "force-close": { + submitLabel: "Submit force-close", + help: "Start a forced closure with signed balances.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-action", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "finalize": { + submitLabel: "Submit finalize", + help: "Finalize a previously forced closure after the waiting period.", + fields: ["field-sig-with", "field-sig-token", "field-sig-token-asset-name"] + }, + "sign-transfer": { + submitLabel: "Sign transfer state", + help: "Generate your signature for an off-chain transfer state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature" + ] + }, + "sign-deposit": { + submitLabel: "Sign deposit state", + help: "Generate your signature for an off-chain deposit state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature" + ] + }, + "sign-withdrawal": { + submitLabel: "Sign withdrawal state", + help: "Generate your signature for an off-chain withdrawal state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature" + ] + }, + "sign-close": { + submitLabel: "Sign close state", + help: "Generate your signature for an off-chain close state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-my-signature" + ] + }, + "request-producer-transfer": { + submitLabel: "Request producer transfer signature", + help: "Send your transfer signature to the producer and receive their signature.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "request-producer-deposit": { + submitLabel: "Request producer deposit signature", + help: "Send your deposit signature to the producer and receive their signature.", + amountLabel: "deposit Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "request-producer-withdrawal": { + submitLabel: "Request producer withdrawal signature", + help: "Send your withdrawal signature to the producer and receive their signature.", + amountLabel: "withdraw Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "request-producer-close": { + submitLabel: "Request producer close signature", + help: "Send your close signature to the producer and receive their signature.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-my-signature", + "field-sig-their-signature" + ] + }, + "submit-signature-state": { + submitLabel: "Submit signature state", + help: "Send the latest signed state to the watchtower.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-action", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature" + ] + } +}; +var PRODUCER_ACTION_CONFIG = { + "request-producer-transfer": { + endpoint: "/producer/transfer", + action: "1" + }, + "request-producer-close": { + endpoint: "/producer/signature-request", + action: "0" + }, + "request-producer-deposit": { + endpoint: "/producer/signature-request", + action: "2" + }, + "request-producer-withdrawal": { + endpoint: "/producer/signature-request", + action: "3" + } +}; +function $(id) { + const node = document.getElementById(id); + if (!node) { + throw new Error(`Missing node: ${id}`); + } + return node; +} +function setStatus(id, message, isError = false) { + const node = $(id); + node.textContent = message; + node.classList.toggle("error", isError); +} +function getInput(id) { + return ( + /** @type {HTMLInputElement | HTMLSelectElement} */ + $(id) + ); +} +function getSelectedAction() { + const selected = normalizedText(getInput(ids.actionSelect).value); + return ACTION_DEFS[selected] ? selected : "fund-pipe"; +} +function setSignedActionForSelection(action) { + const mapping = { + "sign-close": "0", + "sign-transfer": "1", + "sign-deposit": "2", + "sign-withdrawal": "3", + "request-producer-close": "0", + "request-producer-transfer": "1", + "request-producer-deposit": "2", + "request-producer-withdrawal": "3" + }; + const value = mapping[action]; + if (value !== void 0) { + getInput(ids.sigAction).value = value; + } +} +function getProducerActionConfig(action) { + return PRODUCER_ACTION_CONFIG[action] || null; +} +function isProducerRequestAction(action) { + return Boolean(getProducerActionConfig(action)); +} +function updateActionUi() { + const action = getSelectedAction(); + const def = ACTION_DEFS[action]; + for (const fieldId of ACTION_FIELD_IDS) { + const field = document.getElementById(fieldId); + if (!field) { + continue; + } + const shouldShow = def.fields.includes(fieldId); + field.classList.toggle("hidden", !shouldShow); + field.hidden = !shouldShow; + field.style.display = shouldShow ? "" : "none"; + } + $(ids.actionSubmitBtn).textContent = def.submitLabel; + const amountLabel = document.getElementById(ids.callAmountLabel); + if (amountLabel) { + amountLabel.textContent = def.amountLabel || "Amount"; + } + const signAction = action.startsWith("sign-"); + const mySigInput = getInput(ids.sigMySignature); + const mySigLabel = document.getElementById(ids.sigMySignatureLabel); + const mySigHelp = document.getElementById(ids.sigMySignatureHelp); + mySigInput.readOnly = signAction; + mySigInput.classList.toggle("generated-output", signAction); + mySigInput.placeholder = signAction ? "Auto-generated after signing" : "0x..."; + if (mySigLabel) { + mySigLabel.textContent = signAction ? "My Signature (Generated Output)" : "My Signature (RSV hex)"; + } + if (mySigHelp) { + mySigHelp.textContent = signAction ? "Click the submit button to generate this signature. It will auto-fill here." : "Paste your signature, or switch to a sign-* action to generate it here."; + } + if (isProducerRequestAction(action) && !normalizedText(getInput(ids.sigWith).value) && watchtowerProducerPrincipal) { + getInput(ids.sigWith).value = watchtowerProducerPrincipal; + } + let producerHint = ""; + if (isProducerRequestAction(action)) { + if (watchtowerProducerEnabled && watchtowerProducerPrincipal) { + producerHint = ` Producer principal: ${watchtowerProducerPrincipal}.`; + } else { + producerHint = " Producer signing is not reported as enabled by the watchtower."; + } + } + setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${producerHint}`, false); + setSignedActionForSelection(action); +} +function normalizedText(value) { + return String(value || "").trim(); +} +function splitContractPrincipal(contractId) { + const value = normalizedText(contractId); + const parts = value.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid contract id: ${contractId}`); + } + return { + address: parts[0], + name: parts[1] + }; +} +function parseClarityName(value, fieldName) { + const text = normalizedText(value); + if (!text) { + throw new Error(`${fieldName} is required`); + } + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(text)) { + throw new Error(`${fieldName} must be a valid Clarity name`); + } + return text; +} +function inferTokenAssetName(tokenContractId) { + try { + const { name } = splitContractPrincipal(tokenContractId); + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(name)) { + return name; + } + } catch { + } + return null; +} +function getTokenAssetName(tokenContractId) { + if (!tokenContractId) { + return null; + } + const explicit = normalizedText(getInput(ids.sigTokenAssetName).value); + if (explicit) { + return parseClarityName(explicit, "Token asset name"); + } + const inferred = inferTokenAssetName(tokenContractId); + if (inferred) { + return inferred; + } + throw new Error("Token asset name is required for FT post-conditions"); +} +function makePostConditionForTransfer(principal, tokenContractId, amount) { + const builder = Pc.principal(principal).willSendEq(amount); + if (!tokenContractId) { + return builder.ustx(); + } + return builder.ft(tokenContractId, getTokenAssetName(tokenContractId)); +} +function saveConfig() { + const data = { + watchtowerUrl: getInput(ids.watchtowerUrl).value.trim(), + contractId: getInput(ids.contractId).value.trim(), + network: getInput(ids.network).value.trim(), + contractVersion: getInput(ids.contractVersion).value.trim() + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} +function loadConfig() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return; + } + try { + const parsed = JSON.parse(raw); + if (typeof parsed.watchtowerUrl === "string") { + getInput(ids.watchtowerUrl).value = parsed.watchtowerUrl; + } + if (typeof parsed.contractId === "string") { + getInput(ids.contractId).value = parsed.contractId; + } + if (typeof parsed.network === "string") { + getInput(ids.network).value = parsed.network; + } + if (typeof parsed.contractVersion === "string") { + getInput(ids.contractVersion).value = parsed.contractVersion; + } + } catch { + } +} +function defaultConfig() { + getInput(ids.watchtowerUrl).value = window.location.origin; + getInput(ids.contractVersion).value = "0.6.0"; +} +function toBigInt(value, field) { + const text = normalizedText(value); + if (!text) { + throw new Error(`${field} is required`); + } + if (!/^\d+$/.test(text)) { + throw new Error(`${field} must be an unsigned integer`); + } + return BigInt(text); +} +function optionalBigInt(value, field) { + const text = normalizedText(value); + if (!text) { + return null; + } + if (!/^\d+$/.test(text)) { + throw new Error(`${field} must be an unsigned integer`); + } + return BigInt(text); +} +function normalizeHex(value, field, expectedBytes = null) { + const raw = normalizedText(value).toLowerCase(); + if (!raw) { + throw new Error(`${field} is required`); + } + const text = raw.startsWith("0x") ? raw.slice(2) : raw; + if (!/^[0-9a-f]+$/.test(text)) { + throw new Error(`${field} must be hex`); + } + if (expectedBytes !== null && text.length !== expectedBytes * 2) { + throw new Error(`${field} must be ${expectedBytes} bytes`); + } + return `0x${text}`; +} +function optionalHex(value, field, expectedBytes = null) { + const text = normalizedText(value); + if (!text) { + return null; + } + return normalizeHex(text, field, expectedBytes); +} +function hexToBytes(hex) { + const normalized = normalizeHex(hex, "hex"); + const raw = normalized.slice(2); + const output = new Uint8Array(raw.length / 2); + for (let i = 0; i < raw.length; i += 2) { + output[i / 2] = Number.parseInt(raw.slice(i, i + 2), 16); + } + return output; +} +async function sha256(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return new Uint8Array(digest); +} +function compareBytes(left, right) { + const len = Math.min(left.length, right.length); + for (let i = 0; i < len; i += 1) { + if (left[i] < right[i]) { + return -1; + } + if (left[i] > right[i]) { + return 1; + } + } + if (left.length < right.length) { + return -1; + } + if (left.length > right.length) { + return 1; + } + return 0; +} +function canonicalPrincipals(a, b) { + const aBytes = serializeCV(principalCV(a)); + const bBytes = serializeCV(principalCV(b)); + if (compareBytes(aBytes, bBytes) <= 0) { + return { principal1: a, principal2: b }; + } + return { principal1: b, principal2: a }; +} +function optionalPrincipalCv(value) { + const text = normalizedText(value); + return text ? Cl.some(Cl.principal(text)) : Cl.none(); +} +function optionalUIntCv(value) { + return value === null ? Cl.none() : Cl.some(Cl.uint(value)); +} +function optionalSecretCv(secretHex) { + if (!secretHex) { + return Cl.none(); + } + return Cl.some(Cl.buffer(hexToBytes(secretHex))); +} +function signatureToBufferCv(signature) { + return Cl.buffer(hexToBytes(normalizeHex(signature, "signature", 65))); +} +function parseContractId() { + const raw = normalizedText(getInput(ids.contractId).value); + let contractId = raw; + if (contractId.startsWith("'")) { + contractId = contractId.slice(1); + } + if (!contractId.includes(".") && contractId.includes("/")) { + contractId = contractId.replace("/", "."); + } + const parts = contractId.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error("Stackflow contract must be a contract principal"); + } + try { + principalCV(parts[0]); + } catch { + throw new Error("Invalid contract address in contract principal"); + } + getInput(ids.contractId).value = contractId; + return contractId; +} +function parseSignerInputs() { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + const withPrincipal = normalizedText(getInput(ids.sigWith).value); + if (!withPrincipal) { + throw new Error("Counterparty principal is required"); + } + const actorInput = normalizedText(getInput(ids.sigActor).value); + const actor = actorInput || connectedAddress; + const token = normalizedText(getInput(ids.sigToken).value) || null; + const myBalance = toBigInt(getInput(ids.sigMyBalance).value, "My balance"); + const theirBalance = toBigInt( + getInput(ids.sigTheirBalance).value, + "Their balance" + ); + const nonce = toBigInt(getInput(ids.sigNonce).value, "Nonce"); + const action = toBigInt(getInput(ids.sigAction).value, "Action"); + const validAfter = optionalBigInt( + getInput(ids.sigValidAfter).value, + "Valid-after" + ); + const secret = optionalHex( + getInput(ids.sigSecret).value, + "Secret preimage", + 32 + ); + return { + withPrincipal, + actor, + token, + myBalance, + theirBalance, + nonce, + action, + validAfter, + secret + }; +} +function parseActionContext({ requireNonce = false } = {}) { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + const withPrincipal = normalizedText(getInput(ids.sigWith).value); + if (!withPrincipal) { + throw new Error("Counterparty principal is required"); + } + const token = normalizedText(getInput(ids.sigToken).value) || null; + const nonce = requireNonce ? toBigInt(getInput(ids.sigNonce).value, "Nonce") : null; + return { + withPrincipal, + token, + nonce + }; +} +async function getHashedSecretCv(secret) { + if (!secret) { + return Cl.none(); + } + const digest = await sha256(hexToBytes(secret)); + return Cl.some(Cl.buffer(digest)); +} +async function buildStructuredState() { + const contractId = parseContractId(); + const signer = parseSignerInputs(); + const pair = canonicalPrincipals(connectedAddress, signer.withPrincipal); + const balance1 = pair.principal1 === connectedAddress ? signer.myBalance : signer.theirBalance; + const balance2 = pair.principal1 === connectedAddress ? signer.theirBalance : signer.myBalance; + const hashedSecret = await getHashedSecretCv(signer.secret); + const message = Cl.tuple({ + token: optionalPrincipalCv(signer.token), + "principal-1": Cl.principal(pair.principal1), + "principal-2": Cl.principal(pair.principal2), + "balance-1": Cl.uint(balance1), + "balance-2": Cl.uint(balance2), + nonce: Cl.uint(signer.nonce), + action: Cl.uint(signer.action), + actor: Cl.principal(signer.actor), + "hashed-secret": hashedSecret, + "valid-after": optionalUIntCv(signer.validAfter) + }); + const network = normalizedText(getInput(ids.network).value); + const chainId = CHAIN_IDS[network] || CHAIN_IDS.testnet; + const version = normalizedText(getInput(ids.contractVersion).value) || "0.6.0"; + const domain = Cl.tuple({ + name: Cl.stringAscii(contractId), + version: Cl.stringAscii(version), + "chain-id": Cl.uint(chainId) + }); + return { + contractId, + signer, + message, + domain + }; +} +function extractAddress(response) { + const isStacksAddress = (value) => typeof value === "string" && /^S[PMT][A-Z0-9]{38,42}$/i.test(value); + const seen = /* @__PURE__ */ new Set(); + const findAddress = (value) => { + if (value === null || value === void 0) { + return null; + } + if (isStacksAddress(value)) { + return value; + } + if (typeof value !== "object") { + return null; + } + if (seen.has(value)) { + return null; + } + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) { + if (item && typeof item === "object" && String(item.symbol || item.chain || "").toUpperCase().includes("STX") && isStacksAddress(item.address)) { + return item.address; + } + } + for (const item of value) { + const nested = findAddress(item); + if (nested) { + return nested; + } + } + return null; + } + if (isStacksAddress(value.address)) { + return value.address; + } + if (isStacksAddress(value.stxAddress)) { + return value.stxAddress; + } + if (isStacksAddress(value.stacksAddress)) { + return value.stacksAddress; + } + const priorityKeys = [ + "result", + "addresses", + "account", + "accounts", + "stx", + "stacks", + "wallet" + ]; + for (const key of priorityKeys) { + if (key in value) { + const nested = findAddress(value[key]); + if (nested) { + return nested; + } + } + } + for (const nestedValue of Object.values(value)) { + const nested = findAddress(nestedValue); + if (nested) { + return nested; + } + } + return null; + }; + return findAddress(response); +} +async function resolveConnectedAddress(connectResponse = null) { + const initialAddress = extractAddress(connectResponse); + if (initialAddress) { + return initialAddress; + } + const response = await request("getAddresses"); + const address = extractAddress(response); + if (!address) { + const details = JSON.stringify(response); + throw new Error( + `Wallet connected, but no valid STX address found. getAddresses response: ${details.slice(0, 300)}` + ); + } + return address; +} +function extractSignature(response) { + if (!response || typeof response !== "object") { + return null; + } + if (typeof response.signature === "string") { + return response.signature; + } + if (response.result && typeof response.result === "object") { + if (typeof response.result.signature === "string") { + return response.result.signature; + } + } + return null; +} +function extractTxid(response) { + if (!response || typeof response !== "object") { + return null; + } + if (typeof response.txid === "string") { + return response.txid; + } + if (response.result && typeof response.result === "object") { + if (typeof response.result.txid === "string") { + return response.result.txid; + } + } + return null; +} +function buildWatchtowerPayload() { + const parsed = parseSignerInputs(); + const contractId = parseContractId(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65 + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65 + ); + const amount = parsed.action === 2n || parsed.action === 3n ? toBigInt(getInput(ids.callFundAmount).value, "Amount").toString(10) : "0"; + return { + contractId, + forPrincipal: connectedAddress, + withPrincipal: parsed.withPrincipal, + token: parsed.token, + amount, + myBalance: parsed.myBalance.toString(10), + theirBalance: parsed.theirBalance.toString(10), + mySignature, + theirSignature, + nonce: parsed.nonce.toString(10), + action: parsed.action.toString(10), + actor: parsed.actor, + secret: parsed.secret, + validAfter: parsed.validAfter ? parsed.validAfter.toString(10) : null, + beneficialOnly: false + }; +} +function buildProducerRequestPayload(action) { + const config = getProducerActionConfig(action); + if (!config) { + throw new Error(`Unsupported producer action: ${action}`); + } + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + const parsed = parseSignerInputs(); + const contractId = parseContractId(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65 + ); + const amount = config.action === "2" || config.action === "3" ? toBigInt(getInput(ids.callFundAmount).value, "Amount").toString(10) : "0"; + return { + endpoint: config.endpoint, + payload: { + contractId, + forPrincipal: parsed.withPrincipal, + withPrincipal: connectedAddress, + token: parsed.token, + amount, + myBalance: parsed.theirBalance.toString(10), + theirBalance: parsed.myBalance.toString(10), + theirSignature: mySignature, + nonce: parsed.nonce.toString(10), + action: config.action, + actor: parsed.actor, + secret: parsed.secret, + validAfter: parsed.validAfter ? parsed.validAfter.toString(10) : null, + beneficialOnly: false + } + }; +} +function renderPayloadPreview() { + const action = getSelectedAction(); + if (action === "sign-transfer" || action === "sign-deposit" || action === "sign-withdrawal" || action === "sign-close") { + const mySignature = normalizedText(getInput(ids.sigMySignature).value); + if (mySignature) { + $(ids.signaturePayload).textContent = JSON.stringify( + { mySignature }, + null, + 2 + ); + } else { + $(ids.signaturePayload).textContent = "Generated signature appears here."; + } + return; + } + if (isProducerRequestAction(action)) { + try { + const request2 = buildProducerRequestPayload(action); + $(ids.signaturePayload).textContent = JSON.stringify(request2, null, 2); + } catch (error) { + $(ids.signaturePayload).textContent = error instanceof Error ? error.message : "invalid producer request"; + } + return; + } + if (action !== "submit-signature-state") { + $(ids.signaturePayload).textContent = "Payload preview appears for submit-signature-state and producer requests."; + return; + } + try { + const payload = buildWatchtowerPayload(); + $(ids.signaturePayload).textContent = JSON.stringify(payload, null, 2); + } catch (error) { + $(ids.signaturePayload).textContent = error instanceof Error ? error.message : "invalid signature payload"; + } +} +function escapeHtml(value) { + return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function renderPipesPlaceholder(message) { + $(ids.pipesBody).innerHTML = `

${escapeHtml(message)}

`; +} +function toDisplayAmount(value) { + if (value === null || value === void 0 || value === "") { + return "-"; + } + const text = String(value); + if (!/^\d+$/.test(text)) { + return text; + } + return text.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} +function toUintOrNull(value) { + const text = String(value ?? ""); + if (!/^\d+$/.test(text)) { + return null; + } + return BigInt(text); +} +function computeDisplayBalances(pipe, connected) { + const principal1 = pipe.pipeKey?.["principal-1"] || ""; + const principal2 = pipe.pipeKey?.["principal-2"] || ""; + const connectedIs1 = connected === principal1; + const connectedIs2 = connected === principal2; + const mineConfirmed = connectedIs1 ? pipe.balance1 : connectedIs2 ? pipe.balance2 : null; + const theirsConfirmed = connectedIs1 ? pipe.balance2 : connectedIs2 ? pipe.balance1 : null; + const minePending = connectedIs1 ? pipe.pending1Amount : connectedIs2 ? pipe.pending2Amount : null; + const theirsPending = connectedIs1 ? pipe.pending2Amount : connectedIs2 ? pipe.pending1Amount : null; + const minePendingHeight = connectedIs1 ? pipe.pending1BurnHeight : connectedIs2 ? pipe.pending2BurnHeight : null; + const theirsPendingHeight = connectedIs1 ? pipe.pending2BurnHeight : connectedIs2 ? pipe.pending1BurnHeight : null; + const counterparty = connectedIs1 ? principal2 : principal1; + const mineConfirmedUint = toUintOrNull(mineConfirmed); + const minePendingUint = toUintOrNull(minePending); + const theirsConfirmedUint = toUintOrNull(theirsConfirmed); + const theirsPendingUint = toUintOrNull(theirsPending); + const mineEffective = mineConfirmedUint !== null && minePendingUint !== null ? (mineConfirmedUint + minePendingUint).toString(10) : mineConfirmed; + const theirsEffective = theirsConfirmedUint !== null && theirsPendingUint !== null ? (theirsConfirmedUint + theirsPendingUint).toString(10) : theirsConfirmed; + return { + counterparty, + mineConfirmed, + theirsConfirmed, + minePending, + theirsPending, + minePendingHeight, + theirsPendingHeight, + mineEffective, + theirsEffective + }; +} +function pendingText(amount, burnHeight) { + const raw = String(amount ?? ""); + if (!/^\d+$/.test(raw)) { + return "-"; + } + if (raw === "0") { + return "0"; + } + return `${toDisplayAmount(raw)} (burn ${escapeHtml(String(burnHeight ?? "?"))})`; +} +async function fetchJson(url, init2) { + const response = await fetch(url, init2); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const message = typeof body?.error === "string" ? body.error : `${response.status} ${response.statusText}`; + throw new Error(message); + } + return body; +} +function pipeMatchesParticipants(pipe, connected, withPrincipal, token) { + const pipeKey = pipe?.pipeKey; + if (!pipeKey) { + return false; + } + const principal1 = normalizedText(pipeKey["principal-1"]); + const principal2 = normalizedText(pipeKey["principal-2"]); + const pipeToken = pipeKey.token ?? null; + return pipeToken === token && (principal1 === connected && principal2 === withPrincipal || principal2 === connected && principal1 === withPrincipal); +} +async function resolvePipeTotals(withPrincipal, token) { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + const body = await fetchJson( + `${baseUrl}/pipes?limit=500&principal=${encodeURIComponent(connectedAddress)}` + ); + const pipes = Array.isArray(body.pipes) ? body.pipes : []; + const pipe = pipes.find( + (candidate) => pipeMatchesParticipants(candidate, connectedAddress, withPrincipal, token) + ); + if (!pipe) { + throw new Error("Unable to find pipe state for finalize post-condition"); + } + if (!/^\d+$/.test(String(pipe.balance1 ?? "")) || !/^\d+$/.test(String(pipe.balance2 ?? ""))) { + throw new Error("Pipe balances unavailable for finalize post-condition"); + } + return { + balance1: BigInt(pipe.balance1), + balance2: BigInt(pipe.balance2) + }; +} +async function refreshPipes() { + if (!connectedAddress) { + setStatus(ids.walletStatus, "Connect wallet to load pipes.", true); + renderPipesPlaceholder("Connect wallet to load watched pipes."); + return; + } + try { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + const [pipeBody, closureBody] = await Promise.all([ + fetchJson( + `${baseUrl}/pipes?limit=500&principal=${encodeURIComponent(connectedAddress)}` + ), + fetchJson(`${baseUrl}/closures`) + ]); + const pipes = Array.isArray(pipeBody.pipes) ? pipeBody.pipes : []; + const closures = Array.isArray(closureBody.closures) ? closureBody.closures : []; + const closureByPipeId = new Map( + closures.map((item) => [`${item.contractId || ""}|${item.pipeId}`, item]) + ); + if (pipes.length === 0) { + renderPipesPlaceholder("No watched pipes for this wallet."); + return; + } + $(ids.pipesBody).innerHTML = pipes.map((pipe) => { + const balances = computeDisplayBalances(pipe, connectedAddress); + const closure = closureByPipeId.get( + `${pipe.contractId || ""}|${pipe.pipeId}` + ); + const closureText = closure ? `${closure.event} (exp ${closure.expiresAt ?? "?"})` : "-"; + return `
+
+
${escapeHtml(balances.counterparty || "-")}
+
${escapeHtml(pipe.pipeKey?.token ?? "STX")}
+
+
+
+ My confirmed + ${escapeHtml(toDisplayAmount(balances.mineConfirmed))} +
+
+ Their confirmed + ${escapeHtml(toDisplayAmount(balances.theirsConfirmed))} +
+
+ My pending + ${pendingText( + balances.minePending, + balances.minePendingHeight + )} +
+
+ Their pending + ${pendingText( + balances.theirsPending, + balances.theirsPendingHeight + )} +
+
+ My effective + ${escapeHtml(toDisplayAmount(balances.mineEffective))} +
+
+ Their effective + ${escapeHtml(toDisplayAmount(balances.theirsEffective))} +
+
+
+
Nonce: ${escapeHtml(pipe.nonce ?? "-")} | Event: ${escapeHtml(pipe.event ?? "-")} | Source: ${escapeHtml(pipe.source ?? "-")}
+
Closure: ${escapeHtml(closureText)}
+
Pipe: ${escapeHtml(pipe.pipeId ?? "-")}
+
Updated: ${escapeHtml(pipe.updatedAt ?? "-")}
+
+
`; + }).join(""); + } catch (error) { + setStatus( + ids.walletStatus, + error instanceof Error ? error.message : "failed to refresh pipes", + true + ); + } +} +async function callContract(functionName, functionArgs, options = {}) { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + const contract = parseContractId(); + const network = normalizedText(getInput(ids.network).value) || "devnet"; + const postConditions = Array.isArray(options.postConditions) ? options.postConditions : []; + const postConditionMode = options.postConditionMode || "deny"; + const response = await request("stx_callContract", { + contract, + functionName, + functionArgs, + postConditions, + postConditionMode, + network + }); + const txid = extractTxid(response); + return txid || JSON.stringify(response); +} +async function connectWallet() { + try { + const response = await connect(); + connectedAddress = await resolveConnectedAddress(response); + getInput(ids.sigActor).value = connectedAddress; + setStatus(ids.walletStatus, `Connected: ${connectedAddress}`); + await refreshPipes(); + } catch (error) { + setStatus( + ids.walletStatus, + error instanceof Error ? error.message : "wallet connection failed", + true + ); + } +} +async function disconnectWallet() { + try { + await disconnect(); + } finally { + connectedAddress = null; + setStatus(ids.walletStatus, "Wallet disconnected."); + renderPipesPlaceholder("Connect wallet to load watched pipes."); + } +} +async function signStructuredState() { + try { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + const state = await buildStructuredState(); + const response = await request("stx_signStructuredMessage", { + domain: state.domain, + message: state.message + }); + const signature = extractSignature(response); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } + getInput(ids.sigMySignature).value = normalizeHex( + signature, + "Generated signature", + 65 + ); + renderPayloadPreview(); + } catch (error) { + setStatus( + ids.walletStatus, + error instanceof Error ? error.message : "signing failed", + true + ); + } +} +async function submitSignatureState() { + try { + const payload = buildWatchtowerPayload(); + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + const response = await fetch(`${baseUrl}/signature-states`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload) + }); + const body = await response.json().catch(() => ({})); + if (response.status === 409 && body?.reason === "nonce-too-low") { + const incomingNonce = body?.incomingNonce ?? payload.nonce ?? "?"; + const existingNonce = body?.existingNonce ?? body?.state?.nonce ?? "?"; + setStatus( + ids.txResult, + `Signature state rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + true + ); + return; + } + if (!response.ok) { + const message = typeof body?.error === "string" ? body.error : `${response.status} ${response.statusText}`; + throw new Error(message); + } + renderPayloadPreview(); + setStatus( + ids.txResult, + `Signature state stored (stored=${body.stored}, replaced=${body.replaced})` + ); + await refreshPipes(); + } catch (error) { + setStatus( + ids.txResult, + error instanceof Error ? error.message : "submit state failed", + true + ); + } +} +async function requestProducerSignature(action) { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + const requestPayload = buildProducerRequestPayload(action); + const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestPayload.payload) + }); + const body = await response.json().catch(() => ({})); + if (response.status === 409 && body?.reason === "nonce-too-low") { + const incomingNonce = body?.incomingNonce ?? requestPayload.payload.nonce ?? "?"; + const existingNonce = body?.existingNonce ?? body?.state?.nonce ?? "?"; + setStatus( + ids.txResult, + `Producer request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + true + ); + return; + } + if (!response.ok) { + const message = typeof body?.error === "string" ? body.error : `${response.status} ${response.statusText}`; + throw new Error(message); + } + const producerSignature = normalizeHex( + body?.mySignature, + "Producer signature", + 65 + ); + getInput(ids.sigTheirSignature).value = producerSignature; + renderPayloadPreview(); + setStatus( + ids.txResult, + `Producer signature received (stored=${body.stored}, replaced=${body.replaced}).` + ); + await refreshPipes(); +} +function bindInputs() { + const configIds = [ + ids.watchtowerUrl, + ids.contractId, + ids.network, + ids.contractVersion + ]; + for (const id of configIds) { + getInput(id).addEventListener("change", saveConfig); + } + getInput(ids.watchtowerUrl).addEventListener("change", async () => { + await syncNetworkFromWatchtower(); + }); + getInput(ids.actionSelect).addEventListener("change", () => { + updateActionUi(); + renderPayloadPreview(); + }); + const sigInputs = [ + ids.sigWith, + ids.sigActor, + ids.sigToken, + ids.sigTokenAssetName, + ids.sigAction, + ids.sigMyBalance, + ids.sigTheirBalance, + ids.sigNonce, + ids.sigValidAfter, + ids.sigSecret, + ids.sigMySignature, + ids.sigTheirSignature, + ids.callFundAmount + ]; + for (const id of sigInputs) { + getInput(id).addEventListener("input", renderPayloadPreview); + } + getInput(ids.sigToken).addEventListener("change", () => { + const token = normalizedText(getInput(ids.sigToken).value); + if (!token) { + return; + } + const existing = normalizedText(getInput(ids.sigTokenAssetName).value); + if (existing) { + return; + } + const inferred = inferTokenAssetName(token); + if (inferred) { + getInput(ids.sigTokenAssetName).value = inferred; + } + }); +} +function normalizeNetworkName(value) { + const text = normalizedText(value).toLowerCase(); + if (text === "mainnet" || text === "testnet" || text === "devnet" || text === "mocknet") { + return text; + } + return null; +} +async function syncNetworkFromWatchtower() { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + return; + } + try { + const health = await fetchJson(`${baseUrl}/health`); + watchtowerProducerEnabled = Boolean(health?.producerEnabled); + watchtowerProducerPrincipal = typeof health?.producerPrincipal === "string" && normalizedText(health.producerPrincipal) ? health.producerPrincipal : null; + const remoteNetwork = normalizeNetworkName(health?.stacksNetwork); + if (!remoteNetwork) { + return; + } + const uiNetwork = normalizeNetworkName(getInput(ids.network).value); + if (uiNetwork !== remoteNetwork) { + getInput(ids.network).value = remoteNetwork; + saveConfig(); + setStatus( + ids.walletStatus, + `Network auto-synced from watchtower: ${remoteNetwork}` + ); + } + if (isProducerRequestAction(getSelectedAction())) { + updateActionUi(); + renderPayloadPreview(); + } + } catch { + } +} +async function initWalletState() { + try { + if (!isConnected()) { + return; + } + connectedAddress = await resolveConnectedAddress(); + getInput(ids.sigActor).value = connectedAddress; + setStatus(ids.walletStatus, `Connected: ${connectedAddress}`); + await refreshPipes(); + } catch { + connectedAddress = null; + } +} +async function callFundPipe() { + const action = parseActionContext({ requireNonce: true }); + const amount = toBigInt( + getInput(ids.callFundAmount).value, + "fund-pipe amount" + ); + const postConditions = [ + makePostConditionForTransfer(connectedAddress, action.token, amount) + ]; + const txid = await callContract("fund-pipe", [ + optionalPrincipalCv(action.token), + Cl.uint(amount), + Cl.principal(action.withPrincipal), + Cl.uint(action.nonce) + ], { + postConditions, + postConditionMode: "deny" + }); + setStatus(ids.txResult, `fund-pipe submitted: ${txid}`); +} +async function callDeposit() { + const signer = parseSignerInputs(); + const amount = toBigInt( + getInput(ids.callFundAmount).value, + "deposit amount" + ); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65 + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65 + ); + const postConditions = [ + makePostConditionForTransfer(connectedAddress, signer.token, amount) + ]; + const txid = await callContract("deposit", [ + Cl.uint(amount), + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce) + ], { + postConditions, + postConditionMode: "deny" + }); + setStatus(ids.txResult, `deposit submitted: ${txid}`); +} +async function callWithdraw() { + const signer = parseSignerInputs(); + const contractId = parseContractId(); + const amount = toBigInt( + getInput(ids.callFundAmount).value, + "withdraw amount" + ); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65 + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65 + ); + const postConditions = [ + makePostConditionForTransfer(contractId, signer.token, amount) + ]; + const txid = await callContract("withdraw", [ + Cl.uint(amount), + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce) + ], { + postConditions, + postConditionMode: "deny" + }); + setStatus(ids.txResult, `withdraw submitted: ${txid}`); +} +async function callForceCancel() { + const action = parseActionContext(); + const txid = await callContract("force-cancel", [ + optionalPrincipalCv(action.token), + Cl.principal(action.withPrincipal) + ]); + setStatus(ids.txResult, `force-cancel submitted: ${txid}`); +} +async function callFinalize() { + const action = parseActionContext(); + const contractId = parseContractId(); + const totals = await resolvePipeTotals(action.withPrincipal, action.token); + const txid = await callContract("finalize", [ + optionalPrincipalCv(action.token), + Cl.principal(action.withPrincipal) + ], { + postConditions: [ + makePostConditionForTransfer( + contractId, + action.token, + totals.balance1 + totals.balance2 + ) + ], + postConditionMode: "deny" + }); + setStatus(ids.txResult, `finalize submitted: ${txid}`); +} +async function callClosePipe() { + const signer = parseSignerInputs(); + const contractId = parseContractId(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65 + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65 + ); + const total = signer.myBalance + signer.theirBalance; + const txid = await callContract("close-pipe", [ + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce) + ], { + postConditions: [ + makePostConditionForTransfer(contractId, signer.token, total) + ], + postConditionMode: "deny" + }); + setStatus(ids.txResult, `close-pipe submitted: ${txid}`); +} +async function callForceClose() { + const signer = parseSignerInputs(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65 + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65 + ); + const txid = await callContract("force-close", [ + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce), + Cl.uint(signer.action), + Cl.principal(signer.actor), + optionalSecretCv(signer.secret), + optionalUIntCv(signer.validAfter) + ]); + setStatus(ids.txResult, `force-close submitted: ${txid}`); +} +async function executeSelectedAction() { + const action = getSelectedAction(); + setStatus(ids.txResult, ""); + if (action === "fund-pipe") { + await callFundPipe(); + return; + } + if (action === "deposit") { + await callDeposit(); + return; + } + if (action === "withdraw") { + await callWithdraw(); + return; + } + if (action === "force-cancel") { + await callForceCancel(); + return; + } + if (action === "close-pipe") { + await callClosePipe(); + return; + } + if (action === "force-close") { + await callForceClose(); + return; + } + if (action === "finalize") { + await callFinalize(); + return; + } + if (action === "sign-transfer" || action === "sign-deposit" || action === "sign-withdrawal" || action === "sign-close") { + setSignedActionForSelection(action); + await signStructuredState(); + setStatus(ids.txResult, "Signature generated."); + return; + } + if (action === "submit-signature-state") { + await submitSignatureState(); + return; + } + if (isProducerRequestAction(action)) { + await requestProducerSignature(action); + return; + } + throw new Error(`Unsupported action: ${action}`); +} +function wireActions() { + $("connect-btn").addEventListener("click", connectWallet); + $("disconnect-btn").addEventListener("click", disconnectWallet); + $("refresh-pipes-btn").addEventListener("click", refreshPipes); + $(ids.actionSubmitBtn).addEventListener("click", async () => { + try { + await executeSelectedAction(); + } catch (error) { + setStatus( + ids.txResult, + error instanceof Error ? error.message : "action failed", + true + ); + } + }); +} +async function init() { + defaultConfig(); + loadConfig(); + bindInputs(); + wireActions(); + updateActionUi(); + await syncNetworkFromWatchtower(); + await initWalletState(); + if (!connectedAddress) { + renderPipesPlaceholder("Connect wallet to load watched pipes."); + } + renderPayloadPreview(); +} +init(); diff --git a/server/ui/main.src.js b/server/ui/main.src.js new file mode 100644 index 0000000..bcb736d --- /dev/null +++ b/server/ui/main.src.js @@ -0,0 +1,1888 @@ +import { connect, disconnect, isConnected, request } from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +import { + Cl, + Pc, + principalCV, + serializeCV, +} from "https://esm.sh/@stacks/transactions@7.2.0?bundle&target=es2020"; + +const CHAIN_IDS = { + mainnet: 1n, + testnet: 2147483648n, + devnet: 2147483648n, + mocknet: 2147483648n, +}; + +const STORAGE_KEY = "stackflow-console-config-v1"; + +let connectedAddress = null; +let watchtowerProducerEnabled = false; +let watchtowerProducerPrincipal = null; + +const ids = { + watchtowerUrl: "watchtower-url", + contractId: "contract-id", + network: "network", + contractVersion: "contract-version", + walletStatus: "wallet-status", + pipesBody: "pipes-body", + sigWith: "sig-with", + sigActor: "sig-actor", + sigToken: "sig-token", + sigTokenAssetName: "sig-token-asset-name", + sigAction: "sig-action", + sigMyBalance: "sig-my-balance", + sigTheirBalance: "sig-their-balance", + sigNonce: "sig-nonce", + sigValidAfter: "sig-valid-after", + sigSecret: "sig-secret", + sigMySignature: "sig-my-signature", + sigTheirSignature: "sig-their-signature", + signaturePayload: "signature-payload", + txResult: "tx-result", + actionHelp: "action-help", + actionSelect: "action-select", + actionSubmitBtn: "action-submit-btn", + callFundAmount: "call-fund-amount", + callAmountLabel: "call-amount-label", + sigMySignatureLabel: "sig-my-signature-label", + sigMySignatureHelp: "sig-my-signature-help", +}; + +const ACTION_FIELD_IDS = [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-nonce", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-action", + "field-sig-actor", + "field-sig-valid-after", + "field-sig-secret", + "field-sig-my-signature", + "field-sig-their-signature", +]; + +const ACTION_DEFS = { + "fund-pipe": { + submitLabel: "Submit fund-pipe", + help: "Create or add initial liquidity to a pipe on-chain.", + amountLabel: "fund-pipe Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-nonce", + ], + }, + deposit: { + submitLabel: "Submit deposit", + help: "Add funds on-chain using signatures from both parties.", + amountLabel: "deposit Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + withdraw: { + submitLabel: "Submit withdraw", + help: "Withdraw funds on-chain using signatures from both parties.", + amountLabel: "withdraw Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "force-cancel": { + submitLabel: "Submit force-cancel", + help: "Start an on-chain cancellation waiting period for this pipe.", + fields: ["field-sig-with", "field-sig-token"], + }, + "close-pipe": { + submitLabel: "Submit close-pipe", + help: "Cooperatively close a pipe with both signatures.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-token-asset-name", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "force-close": { + submitLabel: "Submit force-close", + help: "Start a forced closure with signed balances.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-action", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "finalize": { + submitLabel: "Submit finalize", + help: "Finalize a previously forced closure after the waiting period.", + fields: ["field-sig-with", "field-sig-token", "field-sig-token-asset-name"], + }, + "sign-transfer": { + submitLabel: "Sign transfer state", + help: "Generate your signature for an off-chain transfer state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + ], + }, + "sign-deposit": { + submitLabel: "Sign deposit state", + help: "Generate your signature for an off-chain deposit state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + ], + }, + "sign-withdrawal": { + submitLabel: "Sign withdrawal state", + help: "Generate your signature for an off-chain withdrawal state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + ], + }, + "sign-close": { + submitLabel: "Sign close state", + help: "Generate your signature for an off-chain close state.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-my-signature", + ], + }, + "request-producer-transfer": { + submitLabel: "Request producer transfer signature", + help: "Send your transfer signature to the producer and receive their signature.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "request-producer-deposit": { + submitLabel: "Request producer deposit signature", + help: "Send your deposit signature to the producer and receive their signature.", + amountLabel: "deposit Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "request-producer-withdrawal": { + submitLabel: "Request producer withdrawal signature", + help: "Send your withdrawal signature to the producer and receive their signature.", + amountLabel: "withdraw Amount", + fields: [ + "field-sig-with", + "field-sig-token", + "field-call-fund-amount", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "request-producer-close": { + submitLabel: "Request producer close signature", + help: "Send your close signature to the producer and receive their signature.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-actor", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, + "submit-signature-state": { + submitLabel: "Submit signature state", + help: "Send the latest signed state to the watchtower.", + fields: [ + "field-sig-with", + "field-sig-token", + "field-sig-my-balance", + "field-sig-their-balance", + "field-sig-nonce", + "field-sig-action", + "field-sig-actor", + "field-sig-secret", + "field-sig-valid-after", + "field-sig-my-signature", + "field-sig-their-signature", + ], + }, +}; + +const PRODUCER_ACTION_CONFIG = { + "request-producer-transfer": { + endpoint: "/producer/transfer", + action: "1", + }, + "request-producer-close": { + endpoint: "/producer/signature-request", + action: "0", + }, + "request-producer-deposit": { + endpoint: "/producer/signature-request", + action: "2", + }, + "request-producer-withdrawal": { + endpoint: "/producer/signature-request", + action: "3", + }, +}; + +function $(id) { + const node = document.getElementById(id); + if (!node) { + throw new Error(`Missing node: ${id}`); + } + return node; +} + +function setStatus(id, message, isError = false) { + const node = $(id); + node.textContent = message; + node.classList.toggle("error", isError); +} + +function getInput(id) { + return /** @type {HTMLInputElement | HTMLSelectElement} */ ($(id)); +} + +function getSelectedAction() { + const selected = normalizedText(getInput(ids.actionSelect).value); + return ACTION_DEFS[selected] ? selected : "fund-pipe"; +} + +function setSignedActionForSelection(action) { + const mapping = { + "sign-close": "0", + "sign-transfer": "1", + "sign-deposit": "2", + "sign-withdrawal": "3", + "request-producer-close": "0", + "request-producer-transfer": "1", + "request-producer-deposit": "2", + "request-producer-withdrawal": "3", + }; + + const value = mapping[action]; + if (value !== undefined) { + getInput(ids.sigAction).value = value; + } +} + +function getProducerActionConfig(action) { + return PRODUCER_ACTION_CONFIG[action] || null; +} + +function isProducerRequestAction(action) { + return Boolean(getProducerActionConfig(action)); +} + +function updateActionUi() { + const action = getSelectedAction(); + const def = ACTION_DEFS[action]; + + for (const fieldId of ACTION_FIELD_IDS) { + const field = document.getElementById(fieldId); + if (!field) { + continue; + } + const shouldShow = def.fields.includes(fieldId); + field.classList.toggle("hidden", !shouldShow); + field.hidden = !shouldShow; + field.style.display = shouldShow ? "" : "none"; + } + + $(ids.actionSubmitBtn).textContent = def.submitLabel; + const amountLabel = document.getElementById(ids.callAmountLabel); + if (amountLabel) { + amountLabel.textContent = def.amountLabel || "Amount"; + } + + const signAction = action.startsWith("sign-"); + const mySigInput = getInput(ids.sigMySignature); + const mySigLabel = document.getElementById(ids.sigMySignatureLabel); + const mySigHelp = document.getElementById(ids.sigMySignatureHelp); + + mySigInput.readOnly = signAction; + mySigInput.classList.toggle("generated-output", signAction); + mySigInput.placeholder = signAction ? "Auto-generated after signing" : "0x..."; + + if (mySigLabel) { + mySigLabel.textContent = signAction + ? "My Signature (Generated Output)" + : "My Signature (RSV hex)"; + } + if (mySigHelp) { + mySigHelp.textContent = signAction + ? "Click the submit button to generate this signature. It will auto-fill here." + : "Paste your signature, or switch to a sign-* action to generate it here."; + } + + if ( + isProducerRequestAction(action) && + !normalizedText(getInput(ids.sigWith).value) && + watchtowerProducerPrincipal + ) { + getInput(ids.sigWith).value = watchtowerProducerPrincipal; + } + + let producerHint = ""; + if (isProducerRequestAction(action)) { + if (watchtowerProducerEnabled && watchtowerProducerPrincipal) { + producerHint = ` Producer principal: ${watchtowerProducerPrincipal}.`; + } else { + producerHint = + " Producer signing is not reported as enabled by the watchtower."; + } + } + + setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${producerHint}`, false); + setSignedActionForSelection(action); +} + +function normalizedText(value) { + return String(value || "").trim(); +} + +function splitContractPrincipal(contractId) { + const value = normalizedText(contractId); + const parts = value.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid contract id: ${contractId}`); + } + + return { + address: parts[0], + name: parts[1], + }; +} + +function parseClarityName(value, fieldName) { + const text = normalizedText(value); + if (!text) { + throw new Error(`${fieldName} is required`); + } + + if (!/^[a-zA-Z][a-zA-Z0-9-]*$/.test(text)) { + throw new Error(`${fieldName} must be a valid Clarity name`); + } + + return text; +} + +function inferTokenAssetName(tokenContractId) { + try { + const { name } = splitContractPrincipal(tokenContractId); + if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(name)) { + return name; + } + } catch { + // Ignore and require explicit name when needed. + } + + return null; +} + +function getTokenAssetName(tokenContractId) { + if (!tokenContractId) { + return null; + } + + const explicit = normalizedText(getInput(ids.sigTokenAssetName).value); + if (explicit) { + return parseClarityName(explicit, "Token asset name"); + } + + const inferred = inferTokenAssetName(tokenContractId); + if (inferred) { + return inferred; + } + + throw new Error("Token asset name is required for FT post-conditions"); +} + +function makePostConditionForTransfer(principal, tokenContractId, amount) { + const builder = Pc.principal(principal).willSendEq(amount); + if (!tokenContractId) { + return builder.ustx(); + } + + return builder.ft(tokenContractId, getTokenAssetName(tokenContractId)); +} + +function saveConfig() { + const data = { + watchtowerUrl: getInput(ids.watchtowerUrl).value.trim(), + contractId: getInput(ids.contractId).value.trim(), + network: getInput(ids.network).value.trim(), + contractVersion: getInput(ids.contractVersion).value.trim(), + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); +} + +function loadConfig() { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return; + } + + try { + const parsed = JSON.parse(raw); + if (typeof parsed.watchtowerUrl === "string") { + getInput(ids.watchtowerUrl).value = parsed.watchtowerUrl; + } + if (typeof parsed.contractId === "string") { + getInput(ids.contractId).value = parsed.contractId; + } + if (typeof parsed.network === "string") { + getInput(ids.network).value = parsed.network; + } + if (typeof parsed.contractVersion === "string") { + getInput(ids.contractVersion).value = parsed.contractVersion; + } + } catch { + // Ignore invalid cached data. + } +} + +function defaultConfig() { + getInput(ids.watchtowerUrl).value = window.location.origin; + getInput(ids.contractVersion).value = "0.6.0"; +} + +function toBigInt(value, field) { + const text = normalizedText(value); + if (!text) { + throw new Error(`${field} is required`); + } + if (!/^\d+$/.test(text)) { + throw new Error(`${field} must be an unsigned integer`); + } + return BigInt(text); +} + +function optionalBigInt(value, field) { + const text = normalizedText(value); + if (!text) { + return null; + } + if (!/^\d+$/.test(text)) { + throw new Error(`${field} must be an unsigned integer`); + } + return BigInt(text); +} + +function normalizeHex(value, field, expectedBytes = null) { + const raw = normalizedText(value).toLowerCase(); + if (!raw) { + throw new Error(`${field} is required`); + } + const text = raw.startsWith("0x") ? raw.slice(2) : raw; + if (!/^[0-9a-f]+$/.test(text)) { + throw new Error(`${field} must be hex`); + } + if (expectedBytes !== null && text.length !== expectedBytes * 2) { + throw new Error(`${field} must be ${expectedBytes} bytes`); + } + return `0x${text}`; +} + +function optionalHex(value, field, expectedBytes = null) { + const text = normalizedText(value); + if (!text) { + return null; + } + return normalizeHex(text, field, expectedBytes); +} + +function hexToBytes(hex) { + const normalized = normalizeHex(hex, "hex"); + const raw = normalized.slice(2); + const output = new Uint8Array(raw.length / 2); + for (let i = 0; i < raw.length; i += 2) { + output[i / 2] = Number.parseInt(raw.slice(i, i + 2), 16); + } + return output; +} + +async function sha256(bytes) { + const digest = await crypto.subtle.digest("SHA-256", bytes); + return new Uint8Array(digest); +} + +function compareBytes(left, right) { + const len = Math.min(left.length, right.length); + for (let i = 0; i < len; i += 1) { + if (left[i] < right[i]) { + return -1; + } + if (left[i] > right[i]) { + return 1; + } + } + if (left.length < right.length) { + return -1; + } + if (left.length > right.length) { + return 1; + } + return 0; +} + +function canonicalPrincipals(a, b) { + const aBytes = serializeCV(principalCV(a)); + const bBytes = serializeCV(principalCV(b)); + if (compareBytes(aBytes, bBytes) <= 0) { + return { principal1: a, principal2: b }; + } + return { principal1: b, principal2: a }; +} + +function optionalPrincipalCv(value) { + const text = normalizedText(value); + return text ? Cl.some(Cl.principal(text)) : Cl.none(); +} + +function optionalUIntCv(value) { + return value === null ? Cl.none() : Cl.some(Cl.uint(value)); +} + +function optionalSecretCv(secretHex) { + if (!secretHex) { + return Cl.none(); + } + return Cl.some(Cl.buffer(hexToBytes(secretHex))); +} + +function signatureToBufferCv(signature) { + return Cl.buffer(hexToBytes(normalizeHex(signature, "signature", 65))); +} + +function parseContractId() { + const raw = normalizedText(getInput(ids.contractId).value); + let contractId = raw; + if (contractId.startsWith("'")) { + contractId = contractId.slice(1); + } + if (!contractId.includes(".") && contractId.includes("/")) { + contractId = contractId.replace("/", "."); + } + + const parts = contractId.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error("Stackflow contract must be a contract principal"); + } + + try { + principalCV(parts[0]); + } catch { + throw new Error("Invalid contract address in contract principal"); + } + + getInput(ids.contractId).value = contractId; + return contractId; +} + +function parseSignerInputs() { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + + const withPrincipal = normalizedText(getInput(ids.sigWith).value); + if (!withPrincipal) { + throw new Error("Counterparty principal is required"); + } + + const actorInput = normalizedText(getInput(ids.sigActor).value); + const actor = actorInput || connectedAddress; + const token = normalizedText(getInput(ids.sigToken).value) || null; + const myBalance = toBigInt(getInput(ids.sigMyBalance).value, "My balance"); + const theirBalance = toBigInt( + getInput(ids.sigTheirBalance).value, + "Their balance", + ); + const nonce = toBigInt(getInput(ids.sigNonce).value, "Nonce"); + const action = toBigInt(getInput(ids.sigAction).value, "Action"); + const validAfter = optionalBigInt( + getInput(ids.sigValidAfter).value, + "Valid-after", + ); + const secret = optionalHex( + getInput(ids.sigSecret).value, + "Secret preimage", + 32, + ); + + return { + withPrincipal, + actor, + token, + myBalance, + theirBalance, + nonce, + action, + validAfter, + secret, + }; +} + +function parseActionContext({ requireNonce = false } = {}) { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + + const withPrincipal = normalizedText(getInput(ids.sigWith).value); + if (!withPrincipal) { + throw new Error("Counterparty principal is required"); + } + + const token = normalizedText(getInput(ids.sigToken).value) || null; + const nonce = requireNonce + ? toBigInt(getInput(ids.sigNonce).value, "Nonce") + : null; + + return { + withPrincipal, + token, + nonce, + }; +} + +async function getHashedSecretCv(secret) { + if (!secret) { + return Cl.none(); + } + const digest = await sha256(hexToBytes(secret)); + return Cl.some(Cl.buffer(digest)); +} + +async function buildStructuredState() { + const contractId = parseContractId(); + const signer = parseSignerInputs(); + const pair = canonicalPrincipals(connectedAddress, signer.withPrincipal); + const balance1 = + pair.principal1 === connectedAddress ? signer.myBalance : signer.theirBalance; + const balance2 = + pair.principal1 === connectedAddress ? signer.theirBalance : signer.myBalance; + + const hashedSecret = await getHashedSecretCv(signer.secret); + const message = Cl.tuple({ + token: optionalPrincipalCv(signer.token), + "principal-1": Cl.principal(pair.principal1), + "principal-2": Cl.principal(pair.principal2), + "balance-1": Cl.uint(balance1), + "balance-2": Cl.uint(balance2), + nonce: Cl.uint(signer.nonce), + action: Cl.uint(signer.action), + actor: Cl.principal(signer.actor), + "hashed-secret": hashedSecret, + "valid-after": optionalUIntCv(signer.validAfter), + }); + + const network = normalizedText(getInput(ids.network).value); + const chainId = CHAIN_IDS[network] || CHAIN_IDS.testnet; + const version = normalizedText(getInput(ids.contractVersion).value) || "0.6.0"; + const domain = Cl.tuple({ + name: Cl.stringAscii(contractId), + version: Cl.stringAscii(version), + "chain-id": Cl.uint(chainId), + }); + + return { + contractId, + signer, + message, + domain, + }; +} + +function extractAddress(response) { + const isStacksAddress = (value) => + typeof value === "string" && /^S[PMT][A-Z0-9]{38,42}$/i.test(value); + + const seen = new Set(); + + const findAddress = (value) => { + if (value === null || value === undefined) { + return null; + } + + if (isStacksAddress(value)) { + return value; + } + + if (typeof value !== "object") { + return null; + } + + if (seen.has(value)) { + return null; + } + seen.add(value); + + if (Array.isArray(value)) { + // Prefer explicit STX-marked entries first. + for (const item of value) { + if ( + item && + typeof item === "object" && + String(item.symbol || item.chain || "").toUpperCase().includes("STX") && + isStacksAddress(item.address) + ) { + return item.address; + } + } + + for (const item of value) { + const nested = findAddress(item); + if (nested) { + return nested; + } + } + return null; + } + + if (isStacksAddress(value.address)) { + return value.address; + } + if (isStacksAddress(value.stxAddress)) { + return value.stxAddress; + } + if (isStacksAddress(value.stacksAddress)) { + return value.stacksAddress; + } + + const priorityKeys = [ + "result", + "addresses", + "account", + "accounts", + "stx", + "stacks", + "wallet", + ]; + + for (const key of priorityKeys) { + if (key in value) { + const nested = findAddress(value[key]); + if (nested) { + return nested; + } + } + } + + for (const nestedValue of Object.values(value)) { + const nested = findAddress(nestedValue); + if (nested) { + return nested; + } + } + + return null; + }; + + return findAddress(response); +} + +async function resolveConnectedAddress(connectResponse = null) { + const initialAddress = extractAddress(connectResponse); + if (initialAddress) { + return initialAddress; + } + + const response = await request("getAddresses"); + const address = extractAddress(response); + if (!address) { + const details = JSON.stringify(response); + throw new Error( + `Wallet connected, but no valid STX address found. getAddresses response: ${details.slice(0, 300)}`, + ); + } + return address; +} + +function extractSignature(response) { + if (!response || typeof response !== "object") { + return null; + } + if (typeof response.signature === "string") { + return response.signature; + } + if (response.result && typeof response.result === "object") { + if (typeof response.result.signature === "string") { + return response.result.signature; + } + } + return null; +} + +function extractTxid(response) { + if (!response || typeof response !== "object") { + return null; + } + if (typeof response.txid === "string") { + return response.txid; + } + if (response.result && typeof response.result === "object") { + if (typeof response.result.txid === "string") { + return response.result.txid; + } + } + return null; +} + +function buildWatchtowerPayload() { + const parsed = parseSignerInputs(); + const contractId = parseContractId(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65, + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65, + ); + const amount = + parsed.action === 2n || parsed.action === 3n + ? toBigInt(getInput(ids.callFundAmount).value, "Amount").toString(10) + : "0"; + + return { + contractId, + forPrincipal: connectedAddress, + withPrincipal: parsed.withPrincipal, + token: parsed.token, + amount, + myBalance: parsed.myBalance.toString(10), + theirBalance: parsed.theirBalance.toString(10), + mySignature, + theirSignature, + nonce: parsed.nonce.toString(10), + action: parsed.action.toString(10), + actor: parsed.actor, + secret: parsed.secret, + validAfter: parsed.validAfter ? parsed.validAfter.toString(10) : null, + beneficialOnly: false, + }; +} + +function buildProducerRequestPayload(action) { + const config = getProducerActionConfig(action); + if (!config) { + throw new Error(`Unsupported producer action: ${action}`); + } + + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + + const parsed = parseSignerInputs(); + const contractId = parseContractId(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65, + ); + const amount = + config.action === "2" || config.action === "3" + ? toBigInt(getInput(ids.callFundAmount).value, "Amount").toString(10) + : "0"; + + return { + endpoint: config.endpoint, + payload: { + contractId, + forPrincipal: parsed.withPrincipal, + withPrincipal: connectedAddress, + token: parsed.token, + amount, + myBalance: parsed.theirBalance.toString(10), + theirBalance: parsed.myBalance.toString(10), + theirSignature: mySignature, + nonce: parsed.nonce.toString(10), + action: config.action, + actor: parsed.actor, + secret: parsed.secret, + validAfter: parsed.validAfter ? parsed.validAfter.toString(10) : null, + beneficialOnly: false, + }, + }; +} + +function renderPayloadPreview() { + const action = getSelectedAction(); + if ( + action === "sign-transfer" || + action === "sign-deposit" || + action === "sign-withdrawal" || + action === "sign-close" + ) { + const mySignature = normalizedText(getInput(ids.sigMySignature).value); + if (mySignature) { + $(ids.signaturePayload).textContent = JSON.stringify( + { mySignature }, + null, + 2, + ); + } else { + $(ids.signaturePayload).textContent = + "Generated signature appears here."; + } + return; + } + + if (isProducerRequestAction(action)) { + try { + const request = buildProducerRequestPayload(action); + $(ids.signaturePayload).textContent = JSON.stringify(request, null, 2); + } catch (error) { + $(ids.signaturePayload).textContent = + error instanceof Error ? error.message : "invalid producer request"; + } + return; + } + + if (action !== "submit-signature-state") { + $(ids.signaturePayload).textContent = + "Payload preview appears for submit-signature-state and producer requests."; + return; + } + + try { + const payload = buildWatchtowerPayload(); + $(ids.signaturePayload).textContent = JSON.stringify(payload, null, 2); + } catch (error) { + $(ids.signaturePayload).textContent = + error instanceof Error ? error.message : "invalid signature payload"; + } +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderPipesPlaceholder(message) { + $(ids.pipesBody).innerHTML = + `

${escapeHtml(message)}

`; +} + +function toDisplayAmount(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + const text = String(value); + if (!/^\d+$/.test(text)) { + return text; + } + + return text.replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +function toUintOrNull(value) { + const text = String(value ?? ""); + if (!/^\d+$/.test(text)) { + return null; + } + return BigInt(text); +} + +function computeDisplayBalances(pipe, connected) { + const principal1 = pipe.pipeKey?.["principal-1"] || ""; + const principal2 = pipe.pipeKey?.["principal-2"] || ""; + const connectedIs1 = connected === principal1; + const connectedIs2 = connected === principal2; + + const mineConfirmed = connectedIs1 + ? pipe.balance1 + : connectedIs2 + ? pipe.balance2 + : null; + const theirsConfirmed = connectedIs1 + ? pipe.balance2 + : connectedIs2 + ? pipe.balance1 + : null; + const minePending = connectedIs1 + ? pipe.pending1Amount + : connectedIs2 + ? pipe.pending2Amount + : null; + const theirsPending = connectedIs1 + ? pipe.pending2Amount + : connectedIs2 + ? pipe.pending1Amount + : null; + const minePendingHeight = connectedIs1 + ? pipe.pending1BurnHeight + : connectedIs2 + ? pipe.pending2BurnHeight + : null; + const theirsPendingHeight = connectedIs1 + ? pipe.pending2BurnHeight + : connectedIs2 + ? pipe.pending1BurnHeight + : null; + const counterparty = connectedIs1 ? principal2 : principal1; + + const mineConfirmedUint = toUintOrNull(mineConfirmed); + const minePendingUint = toUintOrNull(minePending); + const theirsConfirmedUint = toUintOrNull(theirsConfirmed); + const theirsPendingUint = toUintOrNull(theirsPending); + + const mineEffective = + mineConfirmedUint !== null && minePendingUint !== null + ? (mineConfirmedUint + minePendingUint).toString(10) + : mineConfirmed; + const theirsEffective = + theirsConfirmedUint !== null && theirsPendingUint !== null + ? (theirsConfirmedUint + theirsPendingUint).toString(10) + : theirsConfirmed; + + return { + counterparty, + mineConfirmed, + theirsConfirmed, + minePending, + theirsPending, + minePendingHeight, + theirsPendingHeight, + mineEffective, + theirsEffective, + }; +} + +function pendingText(amount, burnHeight) { + const raw = String(amount ?? ""); + if (!/^\d+$/.test(raw)) { + return "-"; + } + + if (raw === "0") { + return "0"; + } + + return `${toDisplayAmount(raw)} (burn ${escapeHtml(String(burnHeight ?? "?"))})`; +} + +async function fetchJson(url, init) { + const response = await fetch(url, init); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const message = + typeof body?.error === "string" + ? body.error + : `${response.status} ${response.statusText}`; + throw new Error(message); + } + return body; +} + +function pipeMatchesParticipants(pipe, connected, withPrincipal, token) { + const pipeKey = pipe?.pipeKey; + if (!pipeKey) { + return false; + } + + const principal1 = normalizedText(pipeKey["principal-1"]); + const principal2 = normalizedText(pipeKey["principal-2"]); + const pipeToken = pipeKey.token ?? null; + + return ( + pipeToken === token && + ((principal1 === connected && principal2 === withPrincipal) || + (principal2 === connected && principal1 === withPrincipal)) + ); +} + +async function resolvePipeTotals(withPrincipal, token) { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + + const body = await fetchJson( + `${baseUrl}/pipes?limit=500&principal=${encodeURIComponent(connectedAddress)}`, + ); + const pipes = Array.isArray(body.pipes) ? body.pipes : []; + const pipe = pipes.find((candidate) => + pipeMatchesParticipants(candidate, connectedAddress, withPrincipal, token), + ); + + if (!pipe) { + throw new Error("Unable to find pipe state for finalize post-condition"); + } + + if (!/^\d+$/.test(String(pipe.balance1 ?? "")) || !/^\d+$/.test(String(pipe.balance2 ?? ""))) { + throw new Error("Pipe balances unavailable for finalize post-condition"); + } + + return { + balance1: BigInt(pipe.balance1), + balance2: BigInt(pipe.balance2), + }; +} + +async function refreshPipes() { + if (!connectedAddress) { + setStatus(ids.walletStatus, "Connect wallet to load pipes.", true); + renderPipesPlaceholder("Connect wallet to load watched pipes."); + return; + } + + try { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + + const [pipeBody, closureBody] = await Promise.all([ + fetchJson( + `${baseUrl}/pipes?limit=500&principal=${encodeURIComponent(connectedAddress)}`, + ), + fetchJson(`${baseUrl}/closures`), + ]); + + const pipes = Array.isArray(pipeBody.pipes) ? pipeBody.pipes : []; + const closures = Array.isArray(closureBody.closures) ? closureBody.closures : []; + const closureByPipeId = new Map( + closures.map((item) => [`${item.contractId || ""}|${item.pipeId}`, item]), + ); + + if (pipes.length === 0) { + renderPipesPlaceholder("No watched pipes for this wallet."); + return; + } + + $(ids.pipesBody).innerHTML = pipes + .map((pipe) => { + const balances = computeDisplayBalances(pipe, connectedAddress); + const closure = closureByPipeId.get( + `${pipe.contractId || ""}|${pipe.pipeId}`, + ); + const closureText = closure + ? `${closure.event} (exp ${closure.expiresAt ?? "?"})` + : "-"; + return `
+
+
${escapeHtml(balances.counterparty || "-")}
+
${escapeHtml(pipe.pipeKey?.token ?? "STX")}
+
+
+
+ My confirmed + ${escapeHtml(toDisplayAmount(balances.mineConfirmed))} +
+
+ Their confirmed + ${escapeHtml(toDisplayAmount(balances.theirsConfirmed))} +
+
+ My pending + ${pendingText( + balances.minePending, + balances.minePendingHeight, + )} +
+
+ Their pending + ${pendingText( + balances.theirsPending, + balances.theirsPendingHeight, + )} +
+
+ My effective + ${escapeHtml(toDisplayAmount(balances.mineEffective))} +
+
+ Their effective + ${escapeHtml(toDisplayAmount(balances.theirsEffective))} +
+
+
+
Nonce: ${escapeHtml(pipe.nonce ?? "-")} | Event: ${escapeHtml(pipe.event ?? "-")} | Source: ${escapeHtml(pipe.source ?? "-")}
+
Closure: ${escapeHtml(closureText)}
+
Pipe: ${escapeHtml(pipe.pipeId ?? "-")}
+
Updated: ${escapeHtml(pipe.updatedAt ?? "-")}
+
+
`; + }) + .join(""); + } catch (error) { + setStatus( + ids.walletStatus, + error instanceof Error ? error.message : "failed to refresh pipes", + true, + ); + } +} + +async function callContract(functionName, functionArgs, options = {}) { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + + const contract = parseContractId(); + const network = normalizedText(getInput(ids.network).value) || "devnet"; + const postConditions = Array.isArray(options.postConditions) + ? options.postConditions + : []; + const postConditionMode = options.postConditionMode || "deny"; + const response = await request("stx_callContract", { + contract, + functionName, + functionArgs, + postConditions, + postConditionMode, + network, + }); + const txid = extractTxid(response); + return txid || JSON.stringify(response); +} + +async function connectWallet() { + try { + const response = await connect(); + connectedAddress = await resolveConnectedAddress(response); + getInput(ids.sigActor).value = connectedAddress; + setStatus(ids.walletStatus, `Connected: ${connectedAddress}`); + await refreshPipes(); + } catch (error) { + setStatus( + ids.walletStatus, + error instanceof Error ? error.message : "wallet connection failed", + true, + ); + } +} + +async function disconnectWallet() { + try { + await disconnect(); + } finally { + connectedAddress = null; + setStatus(ids.walletStatus, "Wallet disconnected."); + renderPipesPlaceholder("Connect wallet to load watched pipes."); + } +} + +async function signStructuredState() { + try { + if (!connectedAddress) { + throw new Error("Connect wallet first"); + } + + const state = await buildStructuredState(); + const response = await request("stx_signStructuredMessage", { + domain: state.domain, + message: state.message, + }); + const signature = extractSignature(response); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } + + getInput(ids.sigMySignature).value = normalizeHex( + signature, + "Generated signature", + 65, + ); + renderPayloadPreview(); + } catch (error) { + setStatus( + ids.walletStatus, + error instanceof Error ? error.message : "signing failed", + true, + ); + } +} + +async function submitSignatureState() { + try { + const payload = buildWatchtowerPayload(); + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + + const response = await fetch(`${baseUrl}/signature-states`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const body = await response.json().catch(() => ({})); + + if (response.status === 409 && body?.reason === "nonce-too-low") { + const incomingNonce = body?.incomingNonce ?? payload.nonce ?? "?"; + const existingNonce = body?.existingNonce ?? body?.state?.nonce ?? "?"; + setStatus( + ids.txResult, + `Signature state rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + true, + ); + return; + } + + if (!response.ok) { + const message = + typeof body?.error === "string" + ? body.error + : `${response.status} ${response.statusText}`; + throw new Error(message); + } + + renderPayloadPreview(); + setStatus( + ids.txResult, + `Signature state stored (stored=${body.stored}, replaced=${body.replaced})`, + ); + await refreshPipes(); + } catch (error) { + setStatus( + ids.txResult, + error instanceof Error ? error.message : "submit state failed", + true, + ); + } +} + +async function requestProducerSignature(action) { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + throw new Error("Watchtower URL is required"); + } + + const requestPayload = buildProducerRequestPayload(action); + const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(requestPayload.payload), + }); + const body = await response.json().catch(() => ({})); + + if (response.status === 409 && body?.reason === "nonce-too-low") { + const incomingNonce = + body?.incomingNonce ?? requestPayload.payload.nonce ?? "?"; + const existingNonce = body?.existingNonce ?? body?.state?.nonce ?? "?"; + setStatus( + ids.txResult, + `Producer request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + true, + ); + return; + } + + if (!response.ok) { + const message = + typeof body?.error === "string" + ? body.error + : `${response.status} ${response.statusText}`; + throw new Error(message); + } + + const producerSignature = normalizeHex( + body?.mySignature, + "Producer signature", + 65, + ); + getInput(ids.sigTheirSignature).value = producerSignature; + renderPayloadPreview(); + setStatus( + ids.txResult, + `Producer signature received (stored=${body.stored}, replaced=${body.replaced}).`, + ); + await refreshPipes(); +} + +function bindInputs() { + const configIds = [ + ids.watchtowerUrl, + ids.contractId, + ids.network, + ids.contractVersion, + ]; + for (const id of configIds) { + getInput(id).addEventListener("change", saveConfig); + } + getInput(ids.watchtowerUrl).addEventListener("change", async () => { + await syncNetworkFromWatchtower(); + }); + getInput(ids.actionSelect).addEventListener("change", () => { + updateActionUi(); + renderPayloadPreview(); + }); + + const sigInputs = [ + ids.sigWith, + ids.sigActor, + ids.sigToken, + ids.sigTokenAssetName, + ids.sigAction, + ids.sigMyBalance, + ids.sigTheirBalance, + ids.sigNonce, + ids.sigValidAfter, + ids.sigSecret, + ids.sigMySignature, + ids.sigTheirSignature, + ids.callFundAmount, + ]; + for (const id of sigInputs) { + getInput(id).addEventListener("input", renderPayloadPreview); + } + + getInput(ids.sigToken).addEventListener("change", () => { + const token = normalizedText(getInput(ids.sigToken).value); + if (!token) { + return; + } + + const existing = normalizedText(getInput(ids.sigTokenAssetName).value); + if (existing) { + return; + } + + const inferred = inferTokenAssetName(token); + if (inferred) { + getInput(ids.sigTokenAssetName).value = inferred; + } + }); +} + +function normalizeNetworkName(value) { + const text = normalizedText(value).toLowerCase(); + if ( + text === "mainnet" || + text === "testnet" || + text === "devnet" || + text === "mocknet" + ) { + return text; + } + return null; +} + +async function syncNetworkFromWatchtower() { + const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + if (!baseUrl) { + return; + } + + try { + const health = await fetchJson(`${baseUrl}/health`); + watchtowerProducerEnabled = Boolean(health?.producerEnabled); + watchtowerProducerPrincipal = + typeof health?.producerPrincipal === "string" && + normalizedText(health.producerPrincipal) + ? health.producerPrincipal + : null; + const remoteNetwork = normalizeNetworkName(health?.stacksNetwork); + if (!remoteNetwork) { + return; + } + + const uiNetwork = normalizeNetworkName(getInput(ids.network).value); + if (uiNetwork !== remoteNetwork) { + getInput(ids.network).value = remoteNetwork; + saveConfig(); + setStatus( + ids.walletStatus, + `Network auto-synced from watchtower: ${remoteNetwork}`, + ); + } + + if (isProducerRequestAction(getSelectedAction())) { + updateActionUi(); + renderPayloadPreview(); + } + } catch { + // Ignore; watchtower may be offline during page load. + } +} + +async function initWalletState() { + try { + if (!isConnected()) { + return; + } + connectedAddress = await resolveConnectedAddress(); + getInput(ids.sigActor).value = connectedAddress; + setStatus(ids.walletStatus, `Connected: ${connectedAddress}`); + await refreshPipes(); + } catch { + connectedAddress = null; + } +} + +async function callFundPipe() { + const action = parseActionContext({ requireNonce: true }); + const amount = toBigInt( + getInput(ids.callFundAmount).value, + "fund-pipe amount", + ); + const postConditions = [ + makePostConditionForTransfer(connectedAddress, action.token, amount), + ]; + + const txid = await callContract("fund-pipe", [ + optionalPrincipalCv(action.token), + Cl.uint(amount), + Cl.principal(action.withPrincipal), + Cl.uint(action.nonce), + ], { + postConditions, + postConditionMode: "deny", + }); + setStatus(ids.txResult, `fund-pipe submitted: ${txid}`); +} + +async function callDeposit() { + const signer = parseSignerInputs(); + const amount = toBigInt( + getInput(ids.callFundAmount).value, + "deposit amount", + ); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65, + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65, + ); + const postConditions = [ + makePostConditionForTransfer(connectedAddress, signer.token, amount), + ]; + + const txid = await callContract("deposit", [ + Cl.uint(amount), + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce), + ], { + postConditions, + postConditionMode: "deny", + }); + setStatus(ids.txResult, `deposit submitted: ${txid}`); +} + +async function callWithdraw() { + const signer = parseSignerInputs(); + const contractId = parseContractId(); + const amount = toBigInt( + getInput(ids.callFundAmount).value, + "withdraw amount", + ); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65, + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65, + ); + const postConditions = [ + makePostConditionForTransfer(contractId, signer.token, amount), + ]; + + const txid = await callContract("withdraw", [ + Cl.uint(amount), + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce), + ], { + postConditions, + postConditionMode: "deny", + }); + setStatus(ids.txResult, `withdraw submitted: ${txid}`); +} + +async function callForceCancel() { + const action = parseActionContext(); + const txid = await callContract("force-cancel", [ + optionalPrincipalCv(action.token), + Cl.principal(action.withPrincipal), + ]); + setStatus(ids.txResult, `force-cancel submitted: ${txid}`); +} + +async function callFinalize() { + const action = parseActionContext(); + const contractId = parseContractId(); + const totals = await resolvePipeTotals(action.withPrincipal, action.token); + const txid = await callContract("finalize", [ + optionalPrincipalCv(action.token), + Cl.principal(action.withPrincipal), + ], { + postConditions: [ + makePostConditionForTransfer( + contractId, + action.token, + totals.balance1 + totals.balance2, + ), + ], + postConditionMode: "deny", + }); + setStatus(ids.txResult, `finalize submitted: ${txid}`); +} + +async function callClosePipe() { + const signer = parseSignerInputs(); + const contractId = parseContractId(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65, + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65, + ); + const total = signer.myBalance + signer.theirBalance; + const txid = await callContract("close-pipe", [ + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce), + ], { + postConditions: [ + makePostConditionForTransfer(contractId, signer.token, total), + ], + postConditionMode: "deny", + }); + setStatus(ids.txResult, `close-pipe submitted: ${txid}`); +} + +async function callForceClose() { + const signer = parseSignerInputs(); + const mySignature = normalizeHex( + getInput(ids.sigMySignature).value, + "My signature", + 65, + ); + const theirSignature = normalizeHex( + getInput(ids.sigTheirSignature).value, + "Counterparty signature", + 65, + ); + const txid = await callContract("force-close", [ + optionalPrincipalCv(signer.token), + Cl.principal(signer.withPrincipal), + Cl.uint(signer.myBalance), + Cl.uint(signer.theirBalance), + signatureToBufferCv(mySignature), + signatureToBufferCv(theirSignature), + Cl.uint(signer.nonce), + Cl.uint(signer.action), + Cl.principal(signer.actor), + optionalSecretCv(signer.secret), + optionalUIntCv(signer.validAfter), + ]); + setStatus(ids.txResult, `force-close submitted: ${txid}`); +} + +async function executeSelectedAction() { + const action = getSelectedAction(); + setStatus(ids.txResult, ""); + + if (action === "fund-pipe") { + await callFundPipe(); + return; + } + + if (action === "deposit") { + await callDeposit(); + return; + } + + if (action === "withdraw") { + await callWithdraw(); + return; + } + + if (action === "force-cancel") { + await callForceCancel(); + return; + } + + if (action === "close-pipe") { + await callClosePipe(); + return; + } + + if (action === "force-close") { + await callForceClose(); + return; + } + + if (action === "finalize") { + await callFinalize(); + return; + } + + if ( + action === "sign-transfer" || + action === "sign-deposit" || + action === "sign-withdrawal" || + action === "sign-close" + ) { + setSignedActionForSelection(action); + await signStructuredState(); + setStatus(ids.txResult, "Signature generated."); + return; + } + + if (action === "submit-signature-state") { + await submitSignatureState(); + return; + } + + if (isProducerRequestAction(action)) { + await requestProducerSignature(action); + return; + } + + throw new Error(`Unsupported action: ${action}`); +} + +function wireActions() { + $("connect-btn").addEventListener("click", connectWallet); + $("disconnect-btn").addEventListener("click", disconnectWallet); + $("refresh-pipes-btn").addEventListener("click", refreshPipes); + + $(ids.actionSubmitBtn).addEventListener("click", async () => { + try { + await executeSelectedAction(); + } catch (error) { + setStatus( + ids.txResult, + error instanceof Error ? error.message : "action failed", + true, + ); + } + }); +} + +async function init() { + defaultConfig(); + loadConfig(); + bindInputs(); + wireActions(); + updateActionUi(); + await syncNetworkFromWatchtower(); + await initWalletState(); + if (!connectedAddress) { + renderPipesPlaceholder("Connect wallet to load watched pipes."); + } + renderPayloadPreview(); +} + +init(); diff --git a/server/ui/styles.css b/server/ui/styles.css new file mode 100644 index 0000000..d300f5f --- /dev/null +++ b/server/ui/styles.css @@ -0,0 +1,248 @@ +:root { + --bg: #f5f8ff; + --ink: #122033; + --muted: #50627d; + --card: #ffffff; + --line: #d4e0f0; + --accent: #0a7f5a; + --accent-strong: #066246; + --warn: #c04a1b; + --shadow: 0 12px 40px rgba(21, 53, 97, 0.1); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 0% 0%, #e5efff 0, transparent 40%), + radial-gradient(circle at 100% 20%, #dff7ea 0, transparent 30%), + var(--bg); + min-height: 100vh; +} + +.layout { + width: min(1200px, 95vw); + margin: 24px auto 40px; + display: grid; + gap: 16px; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px 18px; + box-shadow: var(--shadow); +} + +.hero h1 { + margin: 0 0 8px; + font-size: clamp(1.4rem, 3vw, 2rem); +} + +.hero p { + margin: 0; + color: var(--muted); +} + +h2 { + margin: 0 0 12px; + font-size: 1.1rem; +} + +label { + display: grid; + gap: 6px; + font-size: 0.9rem; + color: var(--muted); +} + +.field-help { + color: var(--muted); + font-size: 0.78rem; +} + +input, +select, +button { + font: inherit; +} + +input, +select { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + padding: 10px 12px; + color: var(--ink); + background: #fff; +} + +input:focus, +select:focus { + outline: 2px solid rgba(10, 127, 90, 0.2); + border-color: var(--accent); +} + +input.generated-output { + background: #eef6ff; + border-color: #b7d2f2; +} + +.grid { + display: grid; + gap: 10px; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid .wide { + grid-column: 1 / -1; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.actions.wrap { + flex-wrap: wrap; +} + +button { + border: 0; + background: var(--accent); + color: #fff; + padding: 10px 14px; + border-radius: 10px; + cursor: pointer; + transition: transform 120ms ease, background-color 120ms ease; +} + +button:hover { + background: var(--accent-strong); + transform: translateY(-1px); +} + +button.ghost { + background: #e5edf9; + color: #27405e; +} + +button.ghost:hover { + background: #d7e3f6; +} + +.status { + margin: 10px 0 0; + color: var(--muted); + word-break: break-word; +} + +.status.error { + color: var(--warn); +} + +.hidden { + display: none !important; +} + +.pipes-list { + display: grid; + gap: 10px; +} + +.pipe-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + background: #fbfdff; +} + +.pipe-card-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.pipe-peer { + font-weight: 700; +} + +.pipe-token { + font-size: 0.82rem; + color: var(--muted); +} + +.pipe-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.pipe-stat { + background: #ffffff; + border: 1px solid #e7eef9; + border-radius: 10px; + padding: 8px; +} + +.pipe-stat-label { + display: block; + color: var(--muted); + font-size: 0.78rem; +} + +.pipe-stat-value { + display: block; + font-weight: 600; + font-size: 0.95rem; +} + +.pipe-meta { + margin-top: 8px; + display: grid; + gap: 4px; + color: var(--muted); + font-size: 0.82rem; +} + +.pipe-card-empty { + color: var(--muted); + text-align: center; +} + +.code { + margin: 12px 0 0; + background: #081321; + color: #c8ffe7; + border-radius: 10px; + padding: 12px; + max-height: 220px; + overflow: auto; + font-size: 0.8rem; +} + +@media (max-width: 860px) { + .grid.two { + grid-template-columns: 1fr; + } + + .actions { + flex-wrap: wrap; + } + + .pipe-stats { + grid-template-columns: 1fr; + } +} diff --git a/tests/producer-service.test.ts b/tests/producer-service.test.ts new file mode 100644 index 0000000..a37c698 --- /dev/null +++ b/tests/producer-service.test.ts @@ -0,0 +1,330 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { createNetwork } from '@stacks/network'; +import { getAddressFromPrivateKey } from '@stacks/transactions'; +import { describe, expect, it } from 'vitest'; + +import { + ProducerService, + ProducerServiceError, + ProducerStateSigner, +} from '../server/src/producer-service.ts'; +import { normalizePipeId } from '../server/src/observer-parser.ts'; +import { canonicalPipeKey } from '../server/src/principal-utils.ts'; +import { AcceptAllSignatureVerifier } from '../server/src/signature-verifier.ts'; +import { SqliteStateStore } from '../server/src/state-store.ts'; +import { Watchtower } from '../server/src/watchtower.ts'; + +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; +const COUNTERPARTY = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const PRODUCER_KEY = + '7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801'; +const COUNTERPARTY_SIGNATURE = `0x${'22'.repeat(65)}`; + +function cleanupDb(store: SqliteStateStore, dbFile: string): void { + store.close(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${dbFile}${suffix}`; + if (fs.existsSync(target)) { + fs.unlinkSync(target); + } + } +} + +function makeHarness({ + signatureVerifierMode = 'accept-all' as const, +}: { + signatureVerifierMode?: 'accept-all' | 'reject-all'; +}) { + const producerAddress = getAddressFromPrivateKey( + PRODUCER_KEY, + createNetwork({ network: 'devnet' }), + ); + const dbFile = path.join( + os.tmpdir(), + `stackflow-producer-${Date.now()}-${Math.random()}.db`, + ); + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 20 }); + store.load(); + + const watchtower = new Watchtower({ + stateStore: store, + watchedPrincipals: [producerAddress], + signatureVerifier: new AcceptAllSignatureVerifier(), + }); + const signer = new ProducerStateSigner({ + stacksNetwork: 'devnet', + stacksApiUrl: null, + signatureVerifierMode, + producerKey: PRODUCER_KEY, + producerPrincipal: null, + stackflowMessageVersion: '0.6.0', + }); + const service = new ProducerService({ watchtower, signer }); + + return { + producerAddress, + dbFile, + store, + service, + }; +} + +function transferPayload(producerAddress: string) { + return { + contractId: CONTRACT_ID, + withPrincipal: COUNTERPARTY, + token: null, + myBalance: '900', + theirBalance: '100', + theirSignature: COUNTERPARTY_SIGNATURE, + nonce: '5', + action: '1', + actor: producerAddress, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +function seedObservedPipeState({ + store, + producerAddress, + withPrincipal, + token, + contractId, + myBalance, + theirBalance, + nonce, +}: { + store: SqliteStateStore; + producerAddress: string; + withPrincipal: string; + token: string | null; + contractId: string; + myBalance: string; + theirBalance: string; + nonce: string; +}): void { + const pipeKey = canonicalPipeKey(token, producerAddress, withPrincipal); + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + throw new Error('failed to build pipe id in test'); + } + + const principal1IsProducer = pipeKey['principal-1'] === producerAddress; + const balance1 = principal1IsProducer ? myBalance : theirBalance; + const balance2 = principal1IsProducer ? theirBalance : myBalance; + const now = new Date().toISOString(); + store.setObservedPipe({ + stateId: `${contractId}|${pipeId}`, + pipeId, + contractId, + pipeKey, + balance1, + balance2, + pending1Amount: null, + pending1BurnHeight: null, + pending2Amount: null, + pending2BurnHeight: null, + expiresAt: null, + nonce, + closer: null, + event: 'fund-pipe', + txid: null, + blockHeight: null, + updatedAt: now, + }); +} + +describe('producer signing service', () => { + it('signs transfer states and stores the latest signature pair', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + producerAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '800', + theirBalance: '200', + nonce: '4', + }); + + const result = await service.signTransfer(transferPayload(producerAddress)); + + expect(result.upsert.stored).toBe(true); + expect(result.upsert.replaced).toBe(false); + expect(result.request.forPrincipal).toBe(producerAddress); + expect(result.request.action).toBe('1'); + expect(result.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + expect(result.upsert.state.mySignature).toBe(result.mySignature); + expect(result.upsert.state.theirSignature).toBe(COUNTERPARTY_SIGNATURE); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('enforces action restrictions on /producer/signature-request', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({}); + + try { + await expect( + service.signSignatureRequest({ + ...transferPayload(producerAddress), + action: '1', + }), + ).rejects.toMatchObject>({ + statusCode: 400, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('rejects requests when reject-all verifier mode is active', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({ + signatureVerifierMode: 'reject-all', + }); + + try { + seedObservedPipeState({ + store, + producerAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '800', + theirBalance: '200', + nonce: '4', + }); + + await expect( + service.signTransfer(transferPayload(producerAddress)), + ).rejects.toMatchObject>({ + statusCode: 401, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('requires amount for withdrawal signature requests', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + producerAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + await expect( + service.signSignatureRequest({ + ...transferPayload(producerAddress), + action: '3', + actor: COUNTERPARTY, + amount: null, + myBalance: '200', + theirBalance: '50', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 400, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('rejects transfer when nonce is not higher than stored state', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + producerAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '800', + theirBalance: '200', + nonce: '4', + }); + + const first = await service.signTransfer(transferPayload(producerAddress)); + expect(first.upsert.stored).toBe(true); + + await expect( + service.signTransfer(transferPayload(producerAddress)), + ).rejects.toMatchObject>({ + statusCode: 409, + details: { + reason: 'nonce-too-low', + existingNonce: '5', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('rejects transfer when producer balance decreases', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + producerAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + await expect( + service.signTransfer({ + ...transferPayload(producerAddress), + myBalance: '150', + theirBalance: '150', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'producer-balance-decrease', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('rejects transfer when no baseline state exists', async () => { + const { producerAddress, dbFile, store, service } = makeHarness({}); + + try { + await expect( + service.signTransfer(transferPayload(producerAddress)), + ).rejects.toMatchObject>({ + statusCode: 409, + details: { + reason: 'unknown-pipe-state', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); +}); diff --git a/tests/watchtower-dispute.test.ts b/tests/watchtower-dispute.test.ts new file mode 100644 index 0000000..11c568c --- /dev/null +++ b/tests/watchtower-dispute.test.ts @@ -0,0 +1,358 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + noneCV, + principalCV, + serializeCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { describe, expect, it } from 'vitest'; + +import { SqliteStateStore } from '../server/src/state-store.ts'; +import { + PrincipalNotWatchedError, + SignatureValidationError, + Watchtower, +} from '../server/src/watchtower.ts'; +import type { + DisputeExecutor, + SignatureVerifier, + SubmitDisputeResult, +} from '../server/src/types.ts'; + +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; + +const SIG_A = `0x${'11'.repeat(65)}`; +const SIG_B = `0x${'22'.repeat(65)}`; + +function cleanupDb(store: SqliteStateStore, dbFile: string): void { + store.close(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${dbFile}${suffix}`; + if (fs.existsSync(target)) { + fs.unlinkSync(target); + } + } +} + +class FakeExecutor implements DisputeExecutor { + readonly enabled = true; + + readonly signerAddress = 'ST3FAKEWATCHTOWERADDRESS'; + + readonly calls: Array<{ forPrincipal: string; txid: string | null }> = []; + + async submitDispute(args: { + signatureState: { forPrincipal: string }; + triggerEvent: { txid: string | null }; + }): Promise { + this.calls.push({ + forPrincipal: args.signatureState.forPrincipal, + txid: args.triggerEvent.txid, + }); + + return { txid: `0xdispute${this.calls.length}` }; + } +} + +class RejectingSignatureVerifier implements SignatureVerifier { + async verifySignatureState() { + return { + valid: false as const, + reason: 'invalid-signature', + }; + } +} + +function makeForceCancelEventHex({ + sender, + nonce, + balance1, + balance2, +}: { + sender: string; + nonce: number; + balance1: number; + balance2: number; +}): string { + return `0x${serializeCV( + tupleCV({ + event: stringAsciiCV('force-cancel'), + sender: principalCV(sender), + 'pipe-key': tupleCV({ + 'principal-1': principalCV(P1), + 'principal-2': principalCV(P2), + token: noneCV(), + }), + pipe: tupleCV({ + 'balance-1': uintCV(balance1), + 'balance-2': uintCV(balance2), + 'expires-at': uintCV(9999), + nonce: uintCV(nonce), + closer: noneCV(), + }), + }), + )}`; +} + +function forceCancelPayload(params: { + txid: string; + sender: string; + nonce: number; + balance1: number; + balance2: number; + blockHeight: number; +}) { + return { + block_height: params.blockHeight, + events: [ + { + txid: params.txid, + contract_event: { + contract_identifier: CONTRACT_ID, + topic: 'print', + raw_value: makeForceCancelEventHex(params), + }, + }, + ], + }; +} + +function makeStore(): { store: SqliteStateStore; dbFile: string } { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-dispute-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 20 }); + store.load(); + + return { store, dbFile }; +} + +describe('watchtower signature + dispute flow', () => { + it('rejects signature states for unwatched principals', async () => { + const { store, dbFile } = makeStore(); + const watchtower = new Watchtower({ + stateStore: store, + watchedPrincipals: [P2], + }); + + await expect( + watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '700', + theirBalance: '300', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: P1, + secret: null, + validAfter: null, + }), + ).rejects.toBeInstanceOf(PrincipalNotWatchedError); + + expect(watchtower.status().signatureStates).toHaveLength(0); + + cleanupDb(store, dbFile); + }); + + it('rejects invalid signatures before storing state', async () => { + const { store, dbFile } = makeStore(); + const watchtower = new Watchtower({ + stateStore: store, + signatureVerifier: new RejectingSignatureVerifier(), + }); + + await expect( + watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '700', + theirBalance: '300', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: P1, + secret: null, + validAfter: null, + }), + ).rejects.toBeInstanceOf(SignatureValidationError); + + expect(watchtower.status().signatureStates).toHaveLength(0); + + cleanupDb(store, dbFile); + }); + + it('stores only the latest signature state by nonce', async () => { + const { store, dbFile } = makeStore(); + const watchtower = new Watchtower({ stateStore: store }); + + const first = await watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '700', + theirBalance: '300', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: P1, + secret: null, + validAfter: null, + }); + + expect(first.stored).toBe(true); + expect(first.replaced).toBe(false); + + const second = await watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '900', + theirBalance: '100', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '4', + action: '1', + actor: P1, + secret: null, + validAfter: null, + }); + + expect(second.stored).toBe(false); + expect(second.reason).toBe('nonce-too-low'); + expect(watchtower.status().signatureStates).toHaveLength(1); + expect(watchtower.status().signatureStates[0].nonce).toBe('5'); + + const third = await watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '850', + theirBalance: '150', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: P1, + secret: null, + validAfter: null, + }); + + expect(third.stored).toBe(false); + expect(third.reason).toBe('nonce-too-low'); + expect(watchtower.status().signatureStates).toHaveLength(1); + expect(watchtower.status().signatureStates[0].myBalance).toBe('700'); + expect(watchtower.status().signatureStates[0].nonce).toBe('5'); + + cleanupDb(store, dbFile); + }); + + it('auto-disputes force-cancel with a newer signature state and avoids duplicate submissions', async () => { + const { store, dbFile } = makeStore(); + const executor = new FakeExecutor(); + const watchtower = new Watchtower({ + stateStore: store, + disputeExecutor: executor, + }); + + await watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '900', + theirBalance: '100', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: P1, + secret: null, + validAfter: null, + }); + + const payload = forceCancelPayload({ + txid: '0xforce1', + sender: P2, + nonce: 3, + balance1: 500, + balance2: 500, + blockHeight: 200, + }); + + await watchtower.ingest(payload, '/new_block'); + expect(executor.calls).toHaveLength(1); + expect(executor.calls[0].forPrincipal).toBe(P1); + + await watchtower.ingest(payload, '/new_block'); + expect(executor.calls).toHaveLength(1); + + const attempts = watchtower.status().disputeAttempts; + expect(attempts).toHaveLength(1); + expect(attempts[0].success).toBe(true); + + cleanupDb(store, dbFile); + }); + + it('skips dispute when beneficial-only is set and state is not better for user', async () => { + const { store, dbFile } = makeStore(); + const executor = new FakeExecutor(); + const watchtower = new Watchtower({ + stateStore: store, + disputeExecutor: executor, + }); + + await watchtower.upsertSignatureState({ + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + myBalance: '400', + theirBalance: '600', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '10', + action: '1', + actor: P1, + secret: null, + validAfter: null, + beneficialOnly: true, + }); + + await watchtower.ingest( + forceCancelPayload({ + txid: '0xforce2', + sender: P2, + nonce: 8, + balance1: 500, + balance2: 500, + blockHeight: 300, + }), + '/new_block', + ); + + expect(executor.calls).toHaveLength(0); + expect(watchtower.status().disputeAttempts).toHaveLength(0); + + cleanupDb(store, dbFile); + }); +}); diff --git a/tests/watchtower-http.integration.test.ts b/tests/watchtower-http.integration.test.ts new file mode 100644 index 0000000..515e82e --- /dev/null +++ b/tests/watchtower-http.integration.test.ts @@ -0,0 +1,556 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { + noneCV, + principalCV, + serializeCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const SERVER_ENTRY = path.join(ROOT, 'server', 'dist', 'index.js'); +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; +const SIG_A = `0x${'11'.repeat(65)}`; +const SIG_B = `0x${'22'.repeat(65)}`; +const RUN_HTTP_INTEGRATION = process.env.WATCHTOWER_HTTP_INTEGRATION === '1'; + +interface Harness { + baseUrl: string; + dbFile: string; + logs: () => string; + stop: () => Promise; + restart: () => Promise; +} + +let built = false; + +beforeAll(() => { + if (!RUN_HTTP_INTEGRATION) { + return; + } + + if (!built) { + execFileSync('npm', ['run', '-s', 'build:watchtower'], { + cwd: ROOT, + stdio: 'pipe', + }); + built = true; + } +}); + +function cleanupDbFiles(dbFile: string): void { + for (const suffix of ['', '-wal', '-shm']) { + const file = `${dbFile}${suffix}`; + if (fs.existsSync(file)) { + fs.unlinkSync(file); + } + } +} + +function forceEventHex({ + eventName, + sender, + nonce, + balance1, + balance2, +}: { + eventName: 'force-close' | 'force-cancel'; + sender: string; + nonce: number; + balance1: number; + balance2: number; +}): string { + return `0x${serializeCV( + tupleCV({ + event: stringAsciiCV(eventName), + sender: principalCV(sender), + 'pipe-key': tupleCV({ + 'principal-1': principalCV(P1), + 'principal-2': principalCV(P2), + token: noneCV(), + }), + pipe: tupleCV({ + 'balance-1': uintCV(balance1), + 'balance-2': uintCV(balance2), + 'expires-at': uintCV(5000), + nonce: uintCV(nonce), + closer: noneCV(), + }), + }), + )}`; +} + +function newBlockPayload({ + txid, + eventName, + sender, + nonce, + balance1, + balance2, +}: { + txid: string; + eventName: 'force-close' | 'force-cancel'; + sender: string; + nonce: number; + balance1: number; + balance2: number; +}) { + return { + block_height: 555, + events: [ + { + txid, + contract_event: { + contract_identifier: CONTRACT_ID, + topic: 'print', + raw_value: forceEventHex({ + eventName, + sender, + nonce, + balance1, + balance2, + }), + }, + }, + ], + }; +} + +function signatureStatePayload(forPrincipal: string) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal: forPrincipal === P1 ? P2 : P1, + token: null, + myBalance: '900', + theirBalance: '100', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: forPrincipal, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('failed to allocate port')); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + server.on('error', reject); + }); +} + +async function waitForHealth( + baseUrl: string, + child: ReturnType, + logsRef: string[], +): Promise { + const deadline = Date.now() + 10000; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `watchtower exited before health check. logs:\n${logsRef.join('')}`, + ); + } + + try { + const response = await fetch(`${baseUrl}/health`); + if (response.status === 200) { + return; + } + } catch { + // ignore + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`watchtower health timeout. logs:\n${logsRef.join('')}`); +} + +async function startHarness({ + dbFile, + extraEnv, +}: { + dbFile: string; + extraEnv: Record; +}): Promise { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const logsRef: string[] = []; + let child = spawn('node', [SERVER_ENTRY], { + cwd: ROOT, + env: { + ...process.env, + WATCHTOWER_HOST: '127.0.0.1', + WATCHTOWER_PORT: String(port), + WATCHTOWER_DB_FILE: dbFile, + STACKFLOW_CONTRACTS: CONTRACT_ID, + ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + + await waitForHealth(baseUrl, child, logsRef); + + const stop = async (): Promise => { + if (child.exitCode !== null) { + return; + } + child.kill('SIGTERM'); + await once(child, 'exit'); + }; + + const restart = async (): Promise => { + await stop(); + child = spawn('node', [SERVER_ENTRY], { + cwd: ROOT, + env: { + ...process.env, + WATCHTOWER_HOST: '127.0.0.1', + WATCHTOWER_PORT: String(port), + WATCHTOWER_DB_FILE: dbFile, + STACKFLOW_CONTRACTS: CONTRACT_ID, + ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + await waitForHealth(baseUrl, child, logsRef); + }; + + return { + baseUrl, + dbFile, + logs: () => logsRef.join(''), + stop, + restart, + }; +} + +const describeHttp = RUN_HTTP_INTEGRATION + ? describe.sequential + : describe.skip; + +describeHttp('watchtower http integration', () => { + it('supports stacks-node observer routes and persists closures across restart', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + WATCHTOWER_PRINCIPALS: `${P1},${P2}`, + WATCHTOWER_SIGNATURE_VERIFIER_MODE: 'accept-all', + WATCHTOWER_DISPUTE_EXECUTOR_MODE: 'noop', + }, + }); + + try { + const badRoute = await fetch(`${harness.baseUrl}/ingest`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(badRoute.status).toBe(404); + + const app = await fetch(`${harness.baseUrl}/app`); + expect(app.status).toBe(200); + + const appScript = await fetch(`${harness.baseUrl}/app/main.js`); + expect(appScript.status).toBe(200); + + const compatRoutes = [ + '/new_burn_block', + '/new_mempool_tx', + '/drop_mempool_tx', + '/new_microblocks', + ]; + for (const route of compatRoutes) { + const response = await fetch(`${harness.baseUrl}${route}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + ok: boolean; + ignored: boolean; + route: string; + }; + expect(body.ok).toBe(true); + expect(body.ignored).toBe(true); + expect(body.route).toBe(route); + } + + const ingest = await fetch(`${harness.baseUrl}/new_block`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + newBlockPayload({ + txid: '0xevent1', + eventName: 'force-close', + sender: P1, + nonce: 4, + balance1: 500, + balance2: 500, + }), + ), + }); + expect(ingest.status).toBe(200); + const ingestBody = (await ingest.json()) as { + ok: boolean; + observedEvents: number; + }; + expect(ingestBody.ok).toBe(true); + expect(ingestBody.observedEvents).toBe(1); + + const closuresResponse = await fetch(`${harness.baseUrl}/closures`); + const closures = (await closuresResponse.json()) as { + closures: Array<{ event: string }>; + }; + expect(closures.closures).toHaveLength(1); + expect(closures.closures[0].event).toBe('force-close'); + + const pipesResponse = await fetch( + `${harness.baseUrl}/pipes?principal=${encodeURIComponent(P1)}`, + ); + expect(pipesResponse.status).toBe(200); + const pipes = (await pipesResponse.json()) as { + pipes: Array<{ + event: string; + balance1: string | null; + balance2: string | null; + nonce: string | null; + }>; + }; + expect(pipes.pipes).toHaveLength(1); + expect(pipes.pipes[0].event).toBe('force-close'); + expect(pipes.pipes[0].balance1).toBe('500'); + expect(pipes.pipes[0].balance2).toBe('500'); + expect(pipes.pipes[0].nonce).toBe('4'); + + await harness.restart(); + + const closuresAfterRestartResponse = await fetch( + `${harness.baseUrl}/closures`, + ); + const closuresAfterRestart = (await closuresAfterRestartResponse.json()) as { + closures: Array<{ event: string }>; + }; + expect(closuresAfterRestart.closures).toHaveLength(1); + expect(closuresAfterRestart.closures[0].event).toBe('force-close'); + } finally { + await harness.stop(); + + const db = new DatabaseSync(dbFile); + const closureCount = db + .prepare('SELECT COUNT(*) as count FROM closures') + .get() as { count: number }; + db.close(); + expect(closureCount.count).toBe(1); + + cleanupDbFiles(dbFile); + } + }); + + it('runs end-to-end signature ingest and mock dispute flow', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + WATCHTOWER_PRINCIPALS: P1, + WATCHTOWER_SIGNATURE_VERIFIER_MODE: 'accept-all', + WATCHTOWER_DISPUTE_EXECUTOR_MODE: 'mock', + }, + }); + + try { + const malformed = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(malformed.status).toBe(400); + + const unwatched = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P3)), + }); + expect(unwatched.status).toBe(403); + + const accepted = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(accepted.status).toBe(200); + const acceptedBody = (await accepted.json()) as { stored: boolean }; + expect(acceptedBody.stored).toBe(true); + + const duplicateNonce = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(duplicateNonce.status).toBe(409); + const duplicateNonceBody = (await duplicateNonce.json()) as { + ok: boolean; + reason: string; + existingNonce: string; + }; + expect(duplicateNonceBody.ok).toBe(false); + expect(duplicateNonceBody.reason).toBe('nonce-too-low'); + expect(duplicateNonceBody.existingNonce).toBe('5'); + + const trigger = await fetch(`${harness.baseUrl}/new_block`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + newBlockPayload({ + txid: '0xevent2', + eventName: 'force-cancel', + sender: P2, + nonce: 3, + balance1: 500, + balance2: 500, + }), + ), + }); + expect(trigger.status).toBe(200); + + const pipesResponse = await fetch( + `${harness.baseUrl}/pipes?principal=${encodeURIComponent(P1)}`, + ); + expect(pipesResponse.status).toBe(200); + const pipes = (await pipesResponse.json()) as { + pipes: Array<{ + event: string; + source: string; + balance1: string | null; + balance2: string | null; + nonce: string | null; + }>; + }; + expect(pipes.pipes).toHaveLength(1); + expect(pipes.pipes[0].event).toBe('signature-state'); + expect(pipes.pipes[0].source).toBe('signature-state'); + expect(pipes.pipes[0].balance1).toBe('900'); + expect(pipes.pipes[0].balance2).toBe('100'); + expect(pipes.pipes[0].nonce).toBe('5'); + + const disputesResponse = await fetch( + `${harness.baseUrl}/dispute-attempts?limit=10`, + ); + const disputes = (await disputesResponse.json()) as { + disputeAttempts: Array<{ + success: boolean; + disputeTxid: string | null; + }>; + }; + expect(disputes.disputeAttempts).toHaveLength(1); + expect(disputes.disputeAttempts[0].success).toBe(true); + expect(disputes.disputeAttempts[0].disputeTxid).toMatch(/^0xmock/); + + await harness.restart(); + + const statesAfterRestartResponse = await fetch( + `${harness.baseUrl}/signature-states?limit=10`, + ); + const statesAfterRestart = (await statesAfterRestartResponse.json()) as { + signatureStates: Array<{ forPrincipal: string }>; + }; + expect(statesAfterRestart.signatureStates).toHaveLength(1); + expect(statesAfterRestart.signatureStates[0].forPrincipal).toBe(P1); + } finally { + await harness.stop(); + + const db = new DatabaseSync(dbFile); + const stateCount = db + .prepare('SELECT COUNT(*) as count FROM signature_states') + .get() as { count: number }; + const attemptCount = db + .prepare('SELECT COUNT(*) as count FROM dispute_attempts') + .get() as { count: number }; + db.close(); + + expect(stateCount.count).toBe(1); + expect(attemptCount.count).toBe(1); + cleanupDbFiles(dbFile); + } + }); + + it('returns 401 when reject-all verifier mode is active', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + WATCHTOWER_PRINCIPALS: P1, + WATCHTOWER_SIGNATURE_VERIFIER_MODE: 'reject-all', + WATCHTOWER_DISPUTE_EXECUTOR_MODE: 'noop', + }, + }); + + try { + const response = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(response.status).toBe(401); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); +}); diff --git a/tests/watchtower-observer.test.ts b/tests/watchtower-observer.test.ts new file mode 100644 index 0000000..3e7f830 --- /dev/null +++ b/tests/watchtower-observer.test.ts @@ -0,0 +1,237 @@ +import { + contractPrincipalCV, + noneCV, + principalCV, + serializeCV, + someCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { describe, expect, it } from 'vitest'; + +import { + extractStackflowPrintEvents, + normalizePipeId, +} from '../server/src/observer-parser.ts'; + +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const CLOSER = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; + +function toHex(cv: ReturnType) { + return `0x${serializeCV(cv)}`; +} + +function printEventHex(eventName: string) { + return toHex( + tupleCV({ + event: stringAsciiCV(eventName), + sender: principalCV(P1), + 'pipe-key': tupleCV({ + 'principal-1': principalCV(P1), + 'principal-2': principalCV(P2), + token: someCV(contractPrincipalCV(CLOSER, 'stackflow-token-0-6-0')), + }), + pipe: tupleCV({ + 'balance-1': uintCV(100), + 'balance-2': uintCV(200), + 'expires-at': uintCV(500), + nonce: uintCV(9), + closer: someCV(principalCV(CLOSER)), + }), + }), + ); +} + +const DEVNET_FUND_PIPE_RAW_VALUE = + '0x0c0000000506616d6f756e7401000000000000000000000000003d0900056576656e740d0000000966756e642d7069706504706970650c000000070962616c616e63652d3101000000000000000000000000000000000962616c616e63652d32010000000000000000000000000000000006636c6f736572090a657870697265732d617401ffffffffffffffffffffffffffffffff056e6f6e636501000000000000000000000000000000000970656e64696e672d310a0c0000000206616d6f756e7401000000000000000000000000003d09000b6275726e2d686569676874010000000000000000000000000000009f0970656e64696e672d320908706970652d6b65790c000000030b7072696e636970616c2d31051a7321b74e2b6a7e949e6c4ad313035b16650950170b7072696e636970616c2d32051aa009ef082269f8c8de591acaa265d61bbebd225105746f6b656e090673656e646572051a7321b74e2b6a7e949e6c4ad313035b1665095017'; + +describe('watchtower event parser', () => { + it('extracts stackflow print events and decodes pipe metadata', () => { + const payload = { + block_height: 123, + events: [ + { + txid: '0xabc', + event_index: 2, + type: 'contract_event', + contract_event: { + contract_identifier: + 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + topic: 'print', + raw_value: printEventHex('force-close'), + }, + }, + ], + }; + + const events = extractStackflowPrintEvents(payload); + expect(events).toHaveLength(1); + + const event = events[0]; + expect(event.eventName).toBe('force-close'); + expect(event.txid).toBe('0xabc'); + expect(event.pipe?.nonce).toBe('9'); + expect(event.pipe?.['expires-at']).toBe('500'); + expect(normalizePipeId(event.pipeKey)).toBe( + `${CLOSER}.stackflow-token-0-6-0|${P1}|${P2}`, + ); + }); + + it('ignores non-stackflow contracts by default', () => { + const payload = { + events: [ + { + txid: '0xdef', + event_index: 1, + contract_event: { + contract_identifier: `${CLOSER}.not-stackflow`, + topic: 'print', + raw_value: printEventHex('force-cancel'), + }, + }, + ], + }; + + const events = extractStackflowPrintEvents(payload); + expect(events).toEqual([]); + }); + + it('does not parse repr when raw_value is missing', () => { + const payload = { + receipts: [ + { + events: [ + { + txid: '0xfeed', + contract_log: { + contract_identifier: + 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + topic: 'print', + value: { + repr: '(tuple (event "finalize") (pipe-key none))', + }, + }, + }, + ], + }, + ], + }; + + const events = extractStackflowPrintEvents(payload); + expect(events).toHaveLength(1); + expect(events[0].eventName).toBeNull(); + expect(events[0].pipeKey).toBeNull(); + expect(events[0].pipe).toBeNull(); + }); + + it('supports raw_value hex when value is repr string', () => { + const payload = { + block_height: 456, + events: [ + { + txid: '0xraw1', + contract_event: { + contract_identifier: + 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + topic: 'print', + value: '(tuple (event "fund-pipe"))', + raw_value: printEventHex('fund-pipe'), + }, + }, + ], + }; + + const events = extractStackflowPrintEvents(payload); + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('fund-pipe'); + expect(events[0].pipeKey).not.toBeNull(); + expect(events[0].pipe?.nonce).toBe('9'); + }); + + it('parses real devnet event envelopes using raw_value only', () => { + const payload = { + events: [ + { + committed: true, + contract_event: { + contract_identifier: `${CLOSER}.stackflow`, + topic: 'print', + raw_value: DEVNET_FUND_PIPE_RAW_VALUE, + value: { + Tuple: { + data_map: { + event: { + Sequence: { String: { ASCII: { data: [102, 117, 110] } } }, + }, + }, + }, + }, + }, + event_index: 1, + txid: '0x350253c9b1a2a8b3eee41d895a24f7650ef30cbeed531c51b0b3d58333e1413b', + type: 'contract_event', + }, + ], + }; + + const events = extractStackflowPrintEvents(payload); + expect(events).toHaveLength(1); + expect(events[0].eventName).toBe('fund-pipe'); + expect(events[0].txid).toBe( + '0x350253c9b1a2a8b3eee41d895a24f7650ef30cbeed531c51b0b3d58333e1413b', + ); + expect(events[0].eventIndex).toBe('1'); + expect(events[0].pipe?.nonce).toBe('0'); + expect(events[0].pipe?.['balance-1']).toBe('0'); + expect(events[0].pipe?.['balance-2']).toBe('0'); + expect(events[0].pipe?.['pending-1']?.amount).toBe('4000000'); + expect(events[0].pipe?.['pending-1']?.['burn-height']).toBe('159'); + expect(events[0].pipe?.['pending-2']).toBeNull(); + expect(events[0].pipeKey?.['principal-1']).toBe(P1); + expect(events[0].pipeKey?.['principal-2']).toBe( + 'ST2G0KVR849MZHJ6YB4DCN8K5TRDVXF92A664PHXT', + ); + expect(events[0].pipeKey?.token).toBeNull(); + }); + + it('supports explicit contract allowlists', () => { + const payload = { + events: [ + { + txid: '0xallow', + contract_event: { + contract_identifier: `${CLOSER}.custom-flow`, + topic: 'print', + raw_value: toHex( + tupleCV({ + event: stringAsciiCV('force-cancel'), + sender: principalCV(P1), + 'pipe-key': tupleCV({ + 'principal-1': principalCV(P1), + 'principal-2': principalCV(P2), + token: noneCV(), + }), + pipe: tupleCV({ + 'balance-1': uintCV(1), + 'balance-2': uintCV(1), + 'expires-at': uintCV(10), + nonce: uintCV(1), + closer: noneCV(), + }), + }), + ), + }, + }, + ], + }; + + const events = extractStackflowPrintEvents(payload, { + watchedContracts: [`${CLOSER}.custom-flow`], + }); + + expect(events).toHaveLength(1); + expect(events[0].contractId).toBe(`${CLOSER}.custom-flow`); + }); +}); diff --git a/tests/watchtower-state.test.ts b/tests/watchtower-state.test.ts new file mode 100644 index 0000000..71296bd --- /dev/null +++ b/tests/watchtower-state.test.ts @@ -0,0 +1,279 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + noneCV, + principalCV, + serializeCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { describe, expect, it } from 'vitest'; + +import { normalizePipeId } from '../server/src/observer-parser.ts'; +import { SqliteStateStore } from '../server/src/state-store.ts'; +import { Watchtower } from '../server/src/watchtower.ts'; + +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; + +function cleanupDb(store: SqliteStateStore, dbFile: string): void { + store.close(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${dbFile}${suffix}`; + if (fs.existsSync(target)) { + fs.unlinkSync(target); + } + } +} + +function forceEventHex( + name: 'fund-pipe' | 'force-close' | 'close-pipe' | 'dispute-closure' | 'finalize', + principal1: string = P1, + principal2: string = P2, +) { + return `0x${serializeCV( + tupleCV({ + event: stringAsciiCV(name), + sender: principalCV(principal1), + 'pipe-key': tupleCV({ + 'principal-1': principalCV(principal1), + 'principal-2': principalCV(principal2), + token: noneCV(), + }), + pipe: tupleCV({ + 'balance-1': uintCV(50), + 'balance-2': uintCV(75), + 'expires-at': uintCV(1000), + nonce: uintCV(4), + closer: noneCV(), + }), + }), + )}`; +} + +function payloadFor( + eventName: 'fund-pipe' | 'force-close' | 'close-pipe' | 'dispute-closure' | 'finalize', + principal1: string = P1, + principal2: string = P2, +) { + return { + block_height: 777, + events: [ + { + txid: `0x${eventName}`, + contract_event: { + contract_identifier: + 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + topic: 'print', + raw_value: forceEventHex(eventName, principal1, principal2), + }, + }, + ], + }; +} + +describe('watchtower state transitions', () => { + it('ignores events for pipes that do not include watched principals', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const watchtower = new Watchtower({ + stateStore: store, + watchedPrincipals: [P1], + }); + + const result = await watchtower.ingest(payloadFor('force-close', P2, P3), '/new_block'); + + expect(result.observedEvents).toBe(0); + expect(watchtower.status().activeClosures).toHaveLength(0); + + cleanupDb(store, dbFile); + }); + + it('tracks closures opened by force-close and zeroes balances on finalize', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const watchtower = new Watchtower({ stateStore: store }); + + await watchtower.ingest(payloadFor('force-close'), '/new_block'); + + let status = watchtower.status(); + expect(status.activeClosures).toHaveLength(1); + expect(status.activeClosures[0].event).toBe('force-close'); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].event).toBe('force-close'); + + await watchtower.ingest(payloadFor('finalize'), '/new_block'); + + status = watchtower.status(); + expect(status.activeClosures).toHaveLength(0); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].event).toBe('finalize'); + expect(status.observedPipes[0].balance1).toBe('0'); + expect(status.observedPipes[0].balance2).toBe('0'); + + cleanupDb(store, dbFile); + }); + + it('tracks on-chain pipe balances from fund-pipe events', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const watchtower = new Watchtower({ stateStore: store }); + await watchtower.ingest(payloadFor('fund-pipe'), '/new_block'); + + const status = watchtower.status(); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].event).toBe('fund-pipe'); + expect(status.observedPipes[0].balance1).toBe('50'); + expect(status.observedPipes[0].balance2).toBe('75'); + expect(status.observedPipes[0].nonce).toBe('4'); + + cleanupDb(store, dbFile); + }); + + it('resets observed pipe balances to zero on dispute-closure', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const watchtower = new Watchtower({ stateStore: store }); + await watchtower.ingest(payloadFor('force-close'), '/new_block'); + + let status = watchtower.status(); + expect(status.activeClosures).toHaveLength(1); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].balance1).toBe('50'); + expect(status.observedPipes[0].balance2).toBe('75'); + + await watchtower.ingest(payloadFor('dispute-closure'), '/new_block'); + + status = watchtower.status(); + expect(status.activeClosures).toHaveLength(0); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].event).toBe('dispute-closure'); + expect(status.observedPipes[0].balance1).toBe('0'); + expect(status.observedPipes[0].balance2).toBe('0'); + expect(status.observedPipes[0].pending1Amount).toBeNull(); + expect(status.observedPipes[0].pending2Amount).toBeNull(); + + cleanupDb(store, dbFile); + }); + + it('resets observed pipe balances to zero on close-pipe', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const watchtower = new Watchtower({ stateStore: store }); + await watchtower.ingest(payloadFor('fund-pipe'), '/new_block'); + + let status = watchtower.status(); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].event).toBe('fund-pipe'); + expect(status.observedPipes[0].balance1).toBe('50'); + expect(status.observedPipes[0].balance2).toBe('75'); + + await watchtower.ingest(payloadFor('close-pipe'), '/new_block'); + + status = watchtower.status(); + expect(status.activeClosures).toHaveLength(0); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].event).toBe('close-pipe'); + expect(status.observedPipes[0].balance1).toBe('0'); + expect(status.observedPipes[0].balance2).toBe('0'); + expect(status.observedPipes[0].pending1Amount).toBeNull(); + expect(status.observedPipes[0].pending2Amount).toBeNull(); + + cleanupDb(store, dbFile); + }); + + it('settles pending balances when burn block height is reached', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const watchtower = new Watchtower({ stateStore: store }); + const pipeKey = { + token: null, + 'principal-1': P1, + 'principal-2': P2, + }; + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + throw new Error('failed to build pipe id'); + } + + store.setObservedPipe({ + stateId: `ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0|${pipeId}`, + pipeId, + contractId: 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + pipeKey, + balance1: '0', + balance2: '0', + pending1Amount: '4000000', + pending1BurnHeight: '159', + pending2Amount: null, + pending2BurnHeight: null, + expiresAt: null, + nonce: '0', + closer: null, + event: 'fund-pipe', + txid: '0xabc', + blockHeight: '153', + updatedAt: new Date().toISOString(), + }); + + const before = await watchtower.ingestBurnBlock(158, '/new_burn_block'); + expect(before.settledPipes).toBe(0); + + let status = watchtower.status(); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].balance1).toBe('0'); + expect(status.observedPipes[0].pending1Amount).toBe('4000000'); + expect(status.observedPipes[0].pending1BurnHeight).toBe('159'); + + const after = await watchtower.ingestBurnBlock(159, '/new_burn_block'); + expect(after.settledPipes).toBe(1); + + status = watchtower.status(); + expect(status.observedPipes).toHaveLength(1); + expect(status.observedPipes[0].balance1).toBe('4000000'); + expect(status.observedPipes[0].pending1Amount).toBeNull(); + expect(status.observedPipes[0].pending1BurnHeight).toBeNull(); + + cleanupDb(store, dbFile); + }); +}); diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..5bfc2be --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "typeRoots": ["./node_modules/@types", "./server/node_modules/@types"], + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": false, + "declaration": false, + "sourceMap": false, + "outDir": "server/dist", + "rootDir": "server/src" + }, + "include": ["server/src/**/*.ts"], + "exclude": ["server/dist", "server/node_modules", "node_modules"] +} From a24b1c0c08406330c0da9a8604a1b84e9b1496bd Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 18 Feb 2026 10:19:19 -0500 Subject: [PATCH 30/78] refactor: cleanup and dedup code --- contracts/stackflow.clar | 225 +++++++++++++++++++++------------------ 1 file changed, 124 insertions(+), 101 deletions(-) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 25bcff2..38ae883 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -59,7 +59,6 @@ (define-constant ERR_INVALID_OTHER_SIGNATURE (err u104)) (define-constant ERR_CONSENSUS_BUFF (err u105)) (define-constant ERR_UNAUTHORIZED (err u106)) -(define-constant ERR_MAX_ALLOWED (err u107)) (define-constant ERR_INVALID_TOTAL_BALANCE (err u108)) (define-constant ERR_WITHDRAWAL_FAILED (err u109)) (define-constant ERR_PIPE_EXPIRED (err u110)) @@ -198,8 +197,9 @@ closer: none, } )) - (updated-pipe (try! (increase-sender-balance pipe-key pipe token amount))) - (closer (get closer pipe)) + (settled-pipe (settle-pending pipe)) + (updated-pipe (try! (increase-sender-balance pipe-key settled-pipe token amount))) + (closer (get closer settled-pipe)) ) ;; If there was an existing pipe, the new nonce must be equal or greater (asserts! (>= nonce (get nonce pipe)) ERR_NONCE_TOO_LOW) @@ -248,15 +248,9 @@ ) (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) - (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq tx-sender principal-1) - my-balance - their-balance - )) - (balance-2 (if (is-eq tx-sender principal-1) - their-balance - my-balance - )) + (signed-balances (map-balances tx-sender pipe-key my-balance their-balance)) + (balance-1 (get balance-1 signed-balances)) + (balance-2 (get balance-2 signed-balances)) (updated-pipe { balance-1: balance-1, balance-2: balance-2, @@ -308,7 +302,7 @@ (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (closer (get closer pipe)) (expires-at (+ burn-block-height WAITING_PERIOD)) - (settled-pipe (settle-pending pipe-key pipe)) + (settled-pipe (settle-pending pipe)) ) ;; A forced closure must not be in progress (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) @@ -374,7 +368,7 @@ (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (pipe-nonce (get nonce pipe)) (closer (get closer pipe)) - (settled-pipe (settle-pending pipe-key pipe)) + (settled-pipe (settle-pending pipe)) ) ;; Exit early if a forced closure is already in progress. (asserts! (is-none closer) ERR_CLOSE_IN_PROGRESS) @@ -407,15 +401,9 @@ ) (let ( (expires-at (+ burn-block-height WAITING_PERIOD)) - (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq tx-sender principal-1) - my-balance - their-balance - )) - (balance-2 (if (is-eq tx-sender principal-1) - their-balance - my-balance - )) + (signed-balances (map-balances tx-sender pipe-key my-balance their-balance)) + (balance-1 (get balance-1 signed-balances)) + (balance-2 (get balance-2 signed-balances)) (new-pipe { balance-1: balance-1, balance-2: balance-2, @@ -582,7 +570,7 @@ ;;; `tx-sender` and `with` for FT `token` (`none` indicates STX). Signatures ;;; must confirm the deposit and the new balances. ;;; Returns: -;;; -`(ok pipe-key)` on success +;;; - `(ok pipe-key)` on success ;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist ;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is in progress ;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce @@ -607,15 +595,10 @@ (principal-1 (get principal-1 pipe-key)) ;; These are the balances that both parties have signed off on, including ;; the deposit amount. - (balance-1 (if (is-eq tx-sender principal-1) - my-balance - their-balance - )) - (balance-2 (if (is-eq tx-sender principal-1) - their-balance - my-balance - )) - (settled-pipe (settle-pending pipe-key pipe)) + (signed-balances (map-balances tx-sender pipe-key my-balance their-balance)) + (balance-1 (get balance-1 signed-balances)) + (balance-2 (get balance-2 signed-balances)) + (settled-pipe (settle-pending pipe)) (pending-1-amount (match (get pending-1 settled-pipe) pending (get amount pending) u0 @@ -675,15 +658,15 @@ ;;; `with` for FT `token` (`none` indicates STX). Signatures must confirm the ;;; withdrawal and the new balances. ;;; Returns: -;;; -`(ok pipe-key)` on success +;;; - `(ok pipe-key)` on success ;;; - `ERR_NO_SUCH_PIPE` if the pipe does not exist ;;; - `ERR_CLOSE_IN_PROGRESS` if a forced closure is in progress ;;; - `ERR_NONCE_TOO_LOW` if the nonce is less than the pipe's saved nonce ;;; - `ERR_INVALID_TOTAL_BALANCE` if the total balance of the pipe is not -;;; equal to the sum of the balances provided and the deposit amount +;;; equal to the prior total balance minus the withdrawal amount ;;; - `ERR_INVALID_SENDER_SIGNATURE` if the sender's signature is invalid ;;; - `ERR_INVALID_OTHER_SIGNATURE` if the other party's signature is invalid -;;; - `ERR_WITHDRAWAL_FAILED` if the deposit fails +;;; - `ERR_WITHDRAWAL_FAILED` if the withdrawal transfer fails (define-public (withdraw (amount uint) (token (optional )) @@ -697,17 +680,11 @@ (let ( (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) - (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq tx-sender principal-1) - my-balance - their-balance - )) - (balance-2 (if (is-eq tx-sender principal-1) - their-balance - my-balance - )) + (signed-balances (map-balances tx-sender pipe-key my-balance their-balance)) + (balance-1 (get balance-1 signed-balances)) + (balance-2 (get balance-2 signed-balances)) ;; Settle any pending deposits that may be in progress - (settled-pipe (settle-pending pipe-key pipe)) + (settled-pipe (settle-pending pipe)) (updated-pipe (merge settled-pipe { balance-1: balance-1, balance-2: balance-2, @@ -755,6 +732,14 @@ ;;; (some { ;;; balance-1: uint, ;;; balance-2: uint, +;;; pending-1: (optional { +;;; amount: uint, +;;; burn-height: uint, +;;; }), +;;; pending-2: (optional { +;;; amount: uint, +;;; burn-height: uint, +;;; }), ;;; expires-at: uint, ;;; nonce: uint, ;;; closer: (optional principal) @@ -1022,6 +1007,26 @@ ) ) +;;; Map caller-relative balances (`my-balance`, `their-balance`) into the +;;; canonical pipe ordering (`balance-1`, `balance-2`). +(define-private (map-balances + (for principal) + (pipe-key { + token: (optional principal), + principal-1: principal, + principal-2: principal, + }) + (my-balance uint) + (their-balance uint) + ) + (let ((principal-1 (get principal-1 pipe-key))) + { + balance-1: (if (is-eq for principal-1) my-balance their-balance), + balance-2: (if (is-eq for principal-1) their-balance my-balance), + } + ) +) + ;;; Transfer `amount` from `tx-sender` to the contract and update the pipe ;;; balances. ;;; Returns: @@ -1053,10 +1058,7 @@ (token (optional )) (amount uint) ) - (let ( - ;; If there are outstanding deposits that can be settled, settle them. - (settled-pipe (settle-pending pipe-key pipe)) - ) + (begin (match token t (unwrap! (contract-call? t transfer amount tx-sender current-contract none) ERR_DEPOSIT_FAILED @@ -1067,16 +1069,16 @@ ) (ok (if (is-eq tx-sender (get principal-1 pipe-key)) (begin - (asserts! (is-none (get pending-1 settled-pipe)) ERR_ALREADY_PENDING) - (merge settled-pipe { pending-1: (some { + (asserts! (is-none (get pending-1 pipe)) ERR_ALREADY_PENDING) + (merge pipe { pending-1: (some { amount: amount, burn-height: (+ burn-block-height CONFIRMATION_DEPTH), }) } ) ) (begin - (asserts! (is-none (get pending-2 settled-pipe)) ERR_ALREADY_PENDING) - (merge settled-pipe { pending-2: (some { + (asserts! (is-none (get pending-2 pipe)) ERR_ALREADY_PENDING) + (merge pipe { pending-2: (some { amount: amount, burn-height: (+ burn-block-height CONFIRMATION_DEPTH), }) } @@ -1133,15 +1135,9 @@ (expires-at (get expires-at pipe)) (pipe-nonce (get nonce pipe)) (closer (unwrap! (get closer pipe) ERR_NO_CLOSE_IN_PROGRESS)) - (principal-1 (get principal-1 pipe-key)) - (balance-1 (if (is-eq for principal-1) - my-balance - their-balance - )) - (balance-2 (if (is-eq for principal-1) - their-balance - my-balance - )) + (signed-balances (map-balances for pipe-key my-balance their-balance)) + (balance-1 (get balance-1 signed-balances)) + (balance-2 (get balance-2 signed-balances)) ) ;; Exit early if this is an attempt to self-dispute (asserts! (not (is-eq for closer)) ERR_SELF_DISPUTE) @@ -1344,14 +1340,9 @@ ) ) -;;; Settle the pending deposit(s) for a pipe. -;;; Returns the updated pipe with deposits settled if possible. +;;; Settle the pending deposit(s) for a pipe at the current burn height. +;;; Returns the updated pipe, without writing it to storage. (define-private (settle-pending - (pipe-key { - token: (optional principal), - principal-1: principal, - principal-2: principal, - }) (pipe { balance-1: uint, balance-2: uint, @@ -1403,11 +1394,59 @@ )) (updated-pipe (merge (merge pipe settle-1) settle-2)) ) - (map-set pipes pipe-key updated-pipe) updated-pipe ) ) +;;; Compute confirmed and pending balances for each side at `at-height`. +(define-private (pipe-balance-state + (pipe { + balance-1: uint, + balance-2: uint, + pending-1: (optional { + amount: uint, + burn-height: uint, + }), + pending-2: (optional { + amount: uint, + burn-height: uint, + }), + expires-at: uint, + nonce: uint, + closer: (optional principal), + }) + (at-height uint) + ) + (let ( + (pipe-balances-1 (calculate-balances + (get balance-1 pipe) + (get pending-1 pipe) + at-height + )) + (pipe-balances-2 (calculate-balances + (get balance-2 pipe) + (get pending-2 pipe) + at-height + )) + (confirmed-1 (get confirmed pipe-balances-1)) + (confirmed-2 (get confirmed pipe-balances-2)) + (pending-1 (get pending pipe-balances-1)) + (pending-2 (get pending pipe-balances-2)) + (confirmed-total (+ confirmed-1 confirmed-2)) + (pending-total (+ pending-1 pending-2)) + ) + { + confirmed-1: confirmed-1, + confirmed-2: confirmed-2, + pending-1: pending-1, + pending-2: pending-2, + confirmed-total: confirmed-total, + pending-total: pending-total, + total: (+ confirmed-total pending-total), + } + ) +) + ;;; Check that the balances provided are legal for the pipe. Each participant ;;; cannot have spent more than their balance, excluding pending deposits. (define-private (balance-check @@ -1423,33 +1462,25 @@ (let ( (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (at-height (default-to burn-block-height at-height-opt)) - (pipe-1 (get balance-1 pipe)) - (pipe-2 (get balance-2 pipe)) - (pipe-pending-1 (get pending-1 pipe)) - (pipe-pending-2 (get pending-2 pipe)) - (pipe-balances-1 (calculate-balances pipe-1 pipe-pending-1 at-height)) - (pipe-balances-2 (calculate-balances pipe-2 pipe-pending-2 at-height)) - (confirmed (+ (get confirmed pipe-balances-1) (get confirmed pipe-balances-2))) - (pending (+ (get pending pipe-balances-1) (get pending pipe-balances-2))) - (pipe-total-sum (+ confirmed pending)) + (state (pipe-balance-state pipe at-height)) (sum (+ balance-1 balance-2)) ) ;; The sum of the balances must be equal to the sum of the pipe balances ;; and the pending deposits. - (asserts! (is-eq sum pipe-total-sum) ERR_INVALID_TOTAL_BALANCE) + (asserts! (is-eq sum (get total state)) ERR_INVALID_TOTAL_BALANCE) ;; Ensure that these balances do not require spending the pending deposits. (asserts! (<= balance-1 - (+ (get confirmed pipe-balances-1) (get pending pipe-balances-1) - (get confirmed pipe-balances-2) + (+ (get confirmed-1 state) (get pending-1 state) + (get confirmed-2 state) )) ERR_INVALID_BALANCES ) (asserts! (<= balance-2 - (+ (get confirmed pipe-balances-2) (get pending pipe-balances-2) - (get confirmed pipe-balances-1) + (+ (get confirmed-2 state) (get pending-2 state) + (get confirmed-1 state) )) ERR_INVALID_BALANCES ) @@ -1481,15 +1512,7 @@ (let ( (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (at-height (default-to burn-block-height at-height-opt)) - (pipe-1 (get balance-1 pipe)) - (pipe-2 (get balance-2 pipe)) - (pipe-pending-1 (get pending-1 pipe)) - (pipe-pending-2 (get pending-2 pipe)) - (pipe-balances-1 (calculate-balances pipe-1 pipe-pending-1 at-height)) - (pipe-balances-2 (calculate-balances pipe-2 pipe-pending-2 at-height)) - (confirmed (+ (get confirmed pipe-balances-1) (get confirmed pipe-balances-2))) - (pending (+ (get pending pipe-balances-1) (get pending pipe-balances-2))) - (pipe-total-sum (+ confirmed pending)) + (state (pipe-balance-state pipe at-height)) (sum (+ balance-1 balance-2)) (principal-1 (get principal-1 pipe-key)) (principal-2 (get principal-2 pipe-key)) @@ -1498,8 +1521,8 @@ balance-2 )) (actor-pending (if (is-eq actor principal-1) - (get pending pipe-balances-1) - (get pending pipe-balances-2) + (get pending-1 state) + (get pending-2 state) )) ) (if (is-eq action ACTION_DEPOSIT) @@ -1510,7 +1533,7 @@ (asserts! (is-none (get closer pipe)) ERR_CLOSE_IN_PROGRESS) (asserts! (>= actor-balance amount) ERR_INVALID_BALANCES) (asserts! (is-eq actor-pending u0) ERR_ALREADY_PENDING) - (asserts! (is-eq sum (+ pipe-total-sum amount)) ERR_INVALID_TOTAL_BALANCE) + (asserts! (is-eq sum (+ (get total state) amount)) ERR_INVALID_TOTAL_BALANCE) (ok true) ) (if (is-eq action ACTION_WITHDRAWAL) @@ -1519,14 +1542,14 @@ ERR_INVALID_PRINCIPAL ) (asserts! (is-none (get closer pipe)) ERR_CLOSE_IN_PROGRESS) - (asserts! (>= confirmed amount) ERR_INVALID_WITHDRAWAL) - (asserts! (is-eq sum (- confirmed amount)) ERR_INVALID_TOTAL_BALANCE) + (asserts! (>= (get confirmed-total state) amount) ERR_INVALID_WITHDRAWAL) + (asserts! (is-eq sum (- (get confirmed-total state) amount)) ERR_INVALID_TOTAL_BALANCE) (ok true) ) (if (is-eq action ACTION_CLOSE) (begin - (asserts! (is-eq pending u0) ERR_PENDING) - (asserts! (is-eq sum confirmed) ERR_INVALID_TOTAL_BALANCE) + (asserts! (is-eq (get pending-total state) u0) ERR_PENDING) + (asserts! (is-eq sum (get confirmed-total state)) ERR_INVALID_TOTAL_BALANCE) (ok true) ) (balance-check pipe-key balance-1 balance-2 at-height-opt) From 3666ee19c806ce28a75fd85a4096a573634efd04 Mon Sep 17 00:00:00 2001 From: obycode Date: Thu, 19 Feb 2026 09:37:47 -0500 Subject: [PATCH 31/78] feat: lots of server improvements and renaming --- .gitignore | 2 +- README.md | 91 +- contracts/stackflow.clar | 2 +- deployments/default.devnet-plan.yaml | 2 +- deployments/default.simnet-plan.yaml | 173 +- package-lock.json | 2066 ++++++++++++----- package.json | 7 +- run-with-devnet.sh | 22 +- server/DESIGN.md | 56 +- server/data/watchtower-state.db | Bin 65536 -> 0 bytes server/src/config.ts | 71 +- server/src/counterparty-service.ts | 1258 ++++++++++ server/src/dispute-executor.ts | 20 +- server/src/index.ts | 189 +- server/src/producer-service.ts | 831 ------- server/src/signature-verifier.ts | 4 +- .../src/{watchtower.ts => stackflow-node.ts} | 64 +- server/src/state-store.ts | 20 +- server/src/types.ts | 25 +- server/ui/index.html | 12 +- server/ui/main.js | 160 +- server/ui/main.src.js | 172 +- ...e.test.ts => counterparty-service.test.ts} | 134 +- tests/watchtower-dispute.test.ts | 52 +- tests/watchtower-http.integration.test.ts | 34 +- tests/watchtower-state.test.ts | 54 +- 26 files changed, 3499 insertions(+), 2022 deletions(-) delete mode 100644 server/data/watchtower-state.db create mode 100644 server/src/counterparty-service.ts delete mode 100644 server/src/producer-service.ts rename server/src/{watchtower.ts => stackflow-node.ts} (86%) rename tests/{producer-service.test.ts => counterparty-service.test.ts} (65%) diff --git a/.gitignore b/.gitignore index 230017b..660f873 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,5 @@ costs-reports.json node_modules .debug_history **/.env -server/data/*.json +server/data/* server/dist/ diff --git a/README.md b/README.md index bbcd6fc..9b2c096 100644 --- a/README.md +++ b/README.md @@ -204,60 +204,67 @@ If the closure is not disputed by the time the waiting period is over, the user may call `finalize` to complete the closure and transfer the appropriate balances to both parties. -# Built-in Watchtower (Event Observer) +# Built-in Stackflow Node Server (Event Observer) -This repo now includes a minimal watchtower service at `server/src/index.ts`. It -is designed to run as a stacks-node event observer, ingest `print` events from +This repo now includes a minimal stackflow-node service at `server/src/index.ts`. It +is designed to run as a Stacks-node event observer, ingest `print` events from Stackflow contracts, store latest signed states, and auto-submit `dispute-closure-for` when a `force-close` or `force-cancel` is observed. Run it with: ```bash -npm run watchtower +npm run stackflow-node ``` Optional environment variables: ```bash -WATCHTOWER_HOST=0.0.0.0 -WATCHTOWER_PORT=8787 -WATCHTOWER_DB_FILE=server/data/watchtower-state.db -WATCHTOWER_MAX_RECENT_EVENTS=500 -WATCHTOWER_LOG_RAW_EVENTS=false +STACKFLOW_NODE_HOST=0.0.0.0 +STACKFLOW_NODE_PORT=8787 +STACKFLOW_NODE_DB_FILE=server/data/stackflow-node-state.db +STACKFLOW_NODE_MAX_RECENT_EVENTS=500 +STACKFLOW_NODE_LOG_RAW_EVENTS=false STACKFLOW_CONTRACTS=ST....stackflow-0-6-0,ST....stackflow-sbtc-0-6-0 -WATCHTOWER_PRINCIPALS=ST...,ST... +STACKFLOW_NODE_PRINCIPALS=ST...,ST... STACKS_NETWORK=devnet STACKS_API_URL=http://localhost:20443 -WATCHTOWER_SIGNER_KEY= -WATCHTOWER_PRODUCER_KEY= -WATCHTOWER_PRODUCER_PRINCIPAL= -WATCHTOWER_PRODUCER_SIGNER_MODE=local-key -WATCHTOWER_STACKFLOW_MESSAGE_VERSION=0.6.0 -WATCHTOWER_SIGNATURE_VERIFIER_MODE=readonly -WATCHTOWER_DISPUTE_EXECUTOR_MODE=auto -WATCHTOWER_DISPUTE_ONLY_BENEFICIAL=false +STACKFLOW_NODE_DISPUTE_SIGNER_KEY= +STACKFLOW_NODE_COUNTERPARTY_KEY= +STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL= +STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE=local-key +STACKFLOW_NODE_COUNTERPARTY_KMS_KEY_ID= +STACKFLOW_NODE_COUNTERPARTY_KMS_REGION= +STACKFLOW_NODE_COUNTERPARTY_KMS_ENDPOINT= +STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION=0.6.0 +STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE=readonly +STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE=auto +STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL=false ``` -If `STACKFLOW_CONTRACTS` is omitted, the watchtower automatically monitors any +If `STACKFLOW_CONTRACTS` is omitted, the stackflow-node automatically monitors any contract identifier matching `*.stackflow*`. -`WATCHTOWER_STATE_FILE` is still accepted as a backward-compatible alias for -`WATCHTOWER_DB_FILE`. The current implementation uses Node's `node:sqlite` module for persistence. -`WATCHTOWER_SIGNATURE_VERIFIER_MODE` supports `readonly` (default), +`STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE` supports `readonly` (default), `accept-all`, and `reject-all`. Non-`readonly` modes are intended for testing. -`WATCHTOWER_DISPUTE_EXECUTOR_MODE` supports `auto` (default), `noop`, and +`STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE` supports `auto` (default), `noop`, and `mock`. `mock` is intended for local integration testing. -`WATCHTOWER_PRODUCER_SIGNER_MODE` currently supports `local-key` (default) and -`kms` (reserved for future signer backends; currently returns `503`). -Set `WATCHTOWER_LOG_RAW_EVENTS=true` to print raw stackflow `print` event +`STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE` supports `local-key` (default) and `kms`. +For `kms`, set `STACKFLOW_NODE_COUNTERPARTY_KMS_KEY_ID`; the server derives the signer +address from the KMS public key at startup. +KMS mode requires the AWS KMS SDK package: `npm install @aws-sdk/client-kms`. +Set `STACKFLOW_NODE_LOG_RAW_EVENTS=true` to print raw stackflow `print` event objects received via `/new_block` for payload inspection/debugging. -If `WATCHTOWER_PRINCIPALS` is set, the watchtower only: +If `STACKFLOW_NODE_PRINCIPALS` is set, the stackflow-node only: 1. accepts `POST /signature-states` for `forPrincipal` values in that list 2. processes closure events for pipes that include at least one listed principal -When `WATCHTOWER_PRINCIPALS` is omitted, it accepts any principal. +When `STACKFLOW_NODE_PRINCIPALS` is omitted: + +1. if counterparty signing is configured (`STACKFLOW_NODE_COUNTERPARTY_KEY` or KMS), it + watches only the derived counterparty principal +2. otherwise, it accepts any principal Health and inspection endpoints: @@ -269,26 +276,26 @@ Health and inspection endpoints: 6. `GET /events?limit=100` 7. `GET /app` (built-in browser UI) -Producer signing endpoints: +Counterparty signing endpoints: -1. `POST /producer/transfer` -2. `POST /producer/signature-request` +1. `POST /counterparty/transfer` +2. `POST /counterparty/signature-request` -`/producer/transfer` signs transfer states (`action = 1`). -`/producer/signature-request` signs close/deposit/withdraw states +`/counterparty/transfer` signs transfer states (`action = 1`). +`/counterparty/signature-request` signs close/deposit/withdraw states (`action = 0|2|3`). For `action = 2|3` (deposit/withdraw), include `amount` in the request body. Both endpoints: 1. check local signing policy against stored state: - reject if nonce is not strictly higher than latest known nonce - - reject if producer balance would decrease - - for transfer (`action = 1`), require producer balance to strictly increase + - reject if counterparty balance would decrease + - for transfer (`action = 1`), require counterparty balance to strictly increase 2. verify the counterparty signature (`verify-signature-request`) -3. generate the producer signature +3. generate the counterparty signature 4. store the full signature pair via the existing signature-state pipeline -Producer signature verification uses `verify-signature-request` (read-only) to +Counterparty signature verification uses `verify-signature-request` (read-only) to apply action-aware on-chain balance logic, including `amount` checks for deposit/withdraw requests. @@ -317,14 +324,14 @@ Example payload: } ``` -The watchtower stores one latest state per `(contract, pipe, forPrincipal)`, +The stackflow-node stores one latest state per `(contract, pipe, forPrincipal)`, replacing only when the incoming nonce is strictly greater. -Before storing, the watchtower verifies signatures by calling the Stackflow +Before storing, the stackflow-node verifies signatures by calling the Stackflow contract read-only function `verify-signatures`. If validation fails, the request returns `401` and nothing is stored. If the incoming nonce is not strictly higher than the stored nonce for that `(contract, pipe, forPrincipal)`, the request returns `409`. -If `forPrincipal` is not in `WATCHTOWER_PRINCIPALS`, the request returns `403`. +If `forPrincipal` is not in the effective watchlist, the request returns `403`. On-chain pipe tracking: @@ -345,7 +352,7 @@ Event observer ingestion endpoint: 2. `POST /new_burn_block` When Clarinet/stacks-node observer config uses `events_keys = ["*"]`, stacks-node -can also call additional observer endpoints. The watchtower responds `200` (no-op) +can also call additional observer endpoints. The stackflow-node responds `200` (no-op) for compatibility on: 1. `POST /new_mempool_tx` @@ -378,7 +385,7 @@ Integration tests for the HTTP server are opt-in (they spawn a real process and bind a local port): ```bash -npm run test:watchtower:http +npm run test:stackflow-node:http ``` # Reference Server Implementation diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 38ae883..e700769 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -8,7 +8,7 @@ ;; MIT License -;; Copyright (c) 2024-2025 obycode, LLC +;; Copyright (c) 2024-2026 obycode, LLC ;; Permission is hereby granted, free of charge, to any person obtaining a copy ;; of this software and associated documentation files (the "Software"), to deal diff --git a/deployments/default.devnet-plan.yaml b/deployments/default.devnet-plan.yaml index e9168ea..4ac995a 100644 --- a/deployments/default.devnet-plan.yaml +++ b/deployments/default.devnet-plan.yaml @@ -35,7 +35,7 @@ plan: - transaction-type: contract-publish contract-name: stackflow expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 491980 + cost: 520800 path: contracts/stackflow.clar anchor-block-only: true clarity-version: 4 diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index 190dff2..2ec03f9 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -1,94 +1,93 @@ ---- id: 0 -name: "Simulated deployment, used as a default for `clarinet console`, `clarinet test` and `clarinet check`" +name: Simulated deployment, used as a default for `clarinet console`, `clarinet test` and `clarinet check` network: simnet genesis: wallets: - - name: deployer - address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: faucet - address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6 - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_1 - address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_2 - address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_3 - address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_4 - address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_5 - address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_6 - address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0 - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_7 - address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ - balance: "100000000000000" - sbtc-balance: "1000000000" - - name: wallet_8 - address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP - balance: "100000000000000" - sbtc-balance: "1000000000" + - name: deployer + address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: faucet + address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6 + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_1 + address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_2 + address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_3 + address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_4 + address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_5 + address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_6 + address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0 + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_7 + address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ + balance: '100000000000000' + sbtc-balance: '1000000000' + - name: wallet_8 + address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP + balance: '100000000000000' + sbtc-balance: '1000000000' contracts: - - genesis - - lockup - - bns - - cost-voting - - costs - - pox - - costs-2 - - pox-2 - - costs-3 - - pox-3 - - pox-4 - - signers - - signers-voting - - costs-4 + - genesis + - lockup + - bns + - cost-voting + - costs + - pox + - costs-2 + - pox-2 + - costs-3 + - pox-3 + - pox-4 + - signers + - signers-voting + - costs-4 plan: batches: - - id: 0 - transactions: - - emulated-contract-publish: - contract-name: sip-010-trait-ft-standard - emulated-sender: SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE - path: "./.cache/requirements/SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.clar" - clarity-version: 1 - epoch: "2.1" - - id: 1 - transactions: - - emulated-contract-publish: - contract-name: stackflow-token - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow-token.clar - clarity-version: 4 - - emulated-contract-publish: - contract-name: reservoir - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/reservoir.clar - clarity-version: 4 - - emulated-contract-publish: - contract-name: stackflow - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow.clar - clarity-version: 4 - - emulated-contract-publish: - contract-name: test-token - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/test-token.clar - clarity-version: 4 - epoch: "3.3" + - id: 0 + transactions: + - transaction-type: emulated-contract-publish + contract-name: sip-010-trait-ft-standard + emulated-sender: SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE + path: ./.cache/requirements/SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.clar + clarity-version: 1 + epoch: '2.0' + - id: 1 + transactions: + - transaction-type: emulated-contract-publish + contract-name: stackflow-token + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/stackflow-token.clar + clarity-version: 4 + - transaction-type: emulated-contract-publish + contract-name: reservoir + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/reservoir.clar + clarity-version: 4 + - transaction-type: emulated-contract-publish + contract-name: stackflow + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/stackflow.clar + clarity-version: 4 + - transaction-type: emulated-contract-publish + contract-name: test-token + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/test-token.clar + clarity-version: 4 + epoch: '3.3' diff --git a/package-lock.json b/package-lock.json index 3a9cbd6..ab33946 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-kms": "^3.993.0", "@stacks/clarinet-sdk": "^3.10.0", "@stacks/connect": "^7.2.0", "@stacks/network": "^7.2.0", @@ -25,6 +26,658 @@ "esbuild": "^0.25.12" } }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-kms": { + "version": "3.993.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kms/-/client-kms-3.993.0.tgz", + "integrity": "sha512-IFXzXqL/aVUbDrEZkV1V4nweH2h3g4y4iF5jSEQtlf7/GchZxZtBCOomcbyQGDh5x4eo52OzpkpwM/hnKJAHQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/credential-provider-node": "^3.972.10", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.11", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.993.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.9", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.2", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/middleware-retry": "^4.4.33", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.32", + "@smithy/util-defaults-mode-node": "^4.2.35", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.993.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.993.0.tgz", + "integrity": "sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.11", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.993.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.9", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.2", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/middleware-retry": "^4.4.33", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.32", + "@smithy/util-defaults-mode-node": "^4.2.35", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.11.tgz", + "integrity": "sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/xml-builder": "^3.972.5", + "@smithy/core": "^3.23.2", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/signature-v4": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.9.tgz", + "integrity": "sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.11.tgz", + "integrity": "sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/types": "^3.973.1", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.9.tgz", + "integrity": "sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/credential-provider-env": "^3.972.9", + "@aws-sdk/credential-provider-http": "^3.972.11", + "@aws-sdk/credential-provider-login": "^3.972.9", + "@aws-sdk/credential-provider-process": "^3.972.9", + "@aws-sdk/credential-provider-sso": "^3.972.9", + "@aws-sdk/credential-provider-web-identity": "^3.972.9", + "@aws-sdk/nested-clients": "3.993.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.9.tgz", + "integrity": "sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/nested-clients": "3.993.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.10.tgz", + "integrity": "sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.9", + "@aws-sdk/credential-provider-http": "^3.972.11", + "@aws-sdk/credential-provider-ini": "^3.972.9", + "@aws-sdk/credential-provider-process": "^3.972.9", + "@aws-sdk/credential-provider-sso": "^3.972.9", + "@aws-sdk/credential-provider-web-identity": "^3.972.9", + "@aws-sdk/types": "^3.973.1", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.9.tgz", + "integrity": "sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.9.tgz", + "integrity": "sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.993.0", + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/token-providers": "3.993.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.9.tgz", + "integrity": "sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/nested-clients": "3.993.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", + "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", + "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", + "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.11.tgz", + "integrity": "sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.993.0", + "@smithy/core": "^3.23.2", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.993.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.993.0.tgz", + "integrity": "sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/middleware-host-header": "^3.972.3", + "@aws-sdk/middleware-logger": "^3.972.3", + "@aws-sdk/middleware-recursion-detection": "^3.972.3", + "@aws-sdk/middleware-user-agent": "^3.972.11", + "@aws-sdk/region-config-resolver": "^3.972.3", + "@aws-sdk/types": "^3.973.1", + "@aws-sdk/util-endpoints": "3.993.0", + "@aws-sdk/util-user-agent-browser": "^3.972.3", + "@aws-sdk/util-user-agent-node": "^3.972.9", + "@smithy/config-resolver": "^4.4.6", + "@smithy/core": "^3.23.2", + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/hash-node": "^4.2.8", + "@smithy/invalid-dependency": "^4.2.8", + "@smithy/middleware-content-length": "^4.2.8", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/middleware-retry": "^4.4.33", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/protocol-http": "^5.3.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.32", + "@smithy/util-defaults-mode-node": "^4.2.35", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", + "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/config-resolver": "^4.4.6", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.993.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.993.0.tgz", + "integrity": "sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.973.11", + "@aws-sdk/nested-clients": "3.993.0", + "@aws-sdk/types": "^3.973.1", + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", + "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.993.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.993.0.tgz", + "integrity": "sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-endpoints": "^3.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", + "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", + "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.1", + "@smithy/types": "^4.12.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.9.tgz", + "integrity": "sha512-JNswdsLdQemxqaSIBL2HRhsHPUBBziAgoi5RQv6/9avmE5g5RSdt1hWr3mHJ7OxqRYf+KeB11ExWbiqfrnoeaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "^3.972.11", + "@aws-sdk/types": "^3.973.1", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz", + "integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "fast-xml-parser": "5.3.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", + "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -472,9 +1125,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -485,9 +1138,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -498,9 +1151,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -511,9 +1164,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -524,9 +1177,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -537,9 +1190,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -550,9 +1203,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -563,9 +1216,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -576,9 +1229,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -589,9 +1242,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -602,9 +1255,22 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -615,9 +1281,22 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -628,9 +1307,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -641,9 +1320,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -654,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -667,9 +1346,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -680,9 +1359,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -692,10 +1371,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -706,9 +1398,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -719,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -732,9 +1424,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -745,9 +1437,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -782,63 +1474,609 @@ "@scure/base": "~1.1.0" } }, - "node_modules/@stacks/auth": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-6.17.0.tgz", - "integrity": "sha512-SaxB6ULkYLRd5WZotymlPzroBn5/28KgJOTY0nKDcwCqxSkYjPZepweA30LK5eUOmePuGILaMTagj1ibZRnvUg==", - "license": "MIT", + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "license": "Apache-2.0", "dependencies": { - "@stacks/common": "^6.16.0", - "@stacks/encryption": "^6.17.0", - "@stacks/network": "^6.17.0", - "@stacks/profile": "^6.17.0", - "cross-fetch": "^3.1.5", - "jsontokens": "^4.0.1" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@stacks/auth/node_modules/@stacks/common": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", - "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", - "license": "MIT", + "node_modules/@smithy/config-resolver": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", + "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "license": "Apache-2.0", "dependencies": { - "@types/bn.js": "^5.1.0", - "@types/node": "^18.0.4" + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@stacks/auth/node_modules/@stacks/network": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", - "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", - "license": "MIT", + "node_modules/@smithy/core": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.2.tgz", + "integrity": "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==", + "license": "Apache-2.0", "dependencies": { - "@stacks/common": "^6.16.0", - "cross-fetch": "^3.1.5" + "@smithy/middleware-serde": "^4.2.9", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-stream": "^4.5.12", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@stacks/auth/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", + "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~5.26.4" + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@stacks/auth/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", + "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", + "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", + "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", + "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.16.tgz", + "integrity": "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.2", + "@smithy/middleware-serde": "^4.2.9", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "@smithy/url-parser": "^4.2.8", + "@smithy/util-middleware": "^4.2.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.33", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.33.tgz", + "integrity": "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/service-error-classification": "^4.2.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-retry": "^4.2.8", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", + "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz", + "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/querystring-builder": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", + "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", + "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", + "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", + "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", + "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", + "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.8", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.11.5", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.5.tgz", + "integrity": "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.23.2", + "@smithy/middleware-endpoint": "^4.4.16", + "@smithy/middleware-stack": "^4.2.8", + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "@smithy/util-stream": "^4.5.12", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", + "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.32", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.32.tgz", + "integrity": "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.35", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.35.tgz", + "integrity": "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.6", + "@smithy/credential-provider-imds": "^4.2.8", + "@smithy/node-config-provider": "^4.3.8", + "@smithy/property-provider": "^4.2.8", + "@smithy/smithy-client": "^4.11.5", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", + "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", + "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", + "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.12", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz", + "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.9", + "@smithy/node-http-handler": "^4.4.10", + "@smithy/types": "^4.12.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stacks/auth": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@stacks/auth/-/auth-7.3.1.tgz", + "integrity": "sha512-8zjQrnthhymJruSWYuP17IRx+c0k8LrOZYH9DRyaTzjEZ+pbvsRUM9v7hH1aGr2LG94BI9249qXpCtGWorVI+g==", + "license": "MIT", + "dependencies": { + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^7.3.1", + "@stacks/encryption": "^7.3.1", + "@stacks/network": "^7.3.1", + "@stacks/profile": "^7.3.1", + "cross-fetch": "^3.1.5", + "jsontokens": "^4.0.1" + } }, "node_modules/@stacks/clarinet-sdk": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk/-/clarinet-sdk-3.11.0.tgz", - "integrity": "sha512-oiZ+x9PibUg4N+CNGSdana/5WRPMll77CGPaiN3Jimcrg0jjYyAYn4Lt6WgYUxxuAj9uLR8JiM5/PetmFx3itw==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk/-/clarinet-sdk-3.14.0.tgz", + "integrity": "sha512-lbDzK/CT/Sspb2IDsxCf9AUQQ2b7VrGFAGd1HYWlgs8Dl4Wu0gb1kXC8cSrkhzLy+lVJz6wHIbwCj2MfJE1/dA==", "license": "GPL-3.0", "peer": true, "dependencies": { - "@stacks/clarinet-sdk-wasm": "3.11.0", + "@stacks/clarinet-sdk-wasm": "3.14.0", "@stacks/transactions": "^7.0.6", "kolorist": "^1.8.0", "prompts": "^2.4.2", @@ -849,108 +2087,81 @@ } }, "node_modules/@stacks/clarinet-sdk-wasm": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.11.0.tgz", - "integrity": "sha512-zJ4AfHIlJl0yLbTlCYqJPI7Jz5FUC9d6HEHupcruHnP+G7LRm8m+Uag2CxpXDkO2A4c60xYbVPNW0KQws7hHbw==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk-wasm/-/clarinet-sdk-wasm-3.14.0.tgz", + "integrity": "sha512-/HE09fg76TxgYIZfSq+72QJAyYL9o9jHnwc/upbBOaj+wjYGqvuyruRR2OHEbGiWcA9BfTJHioB9yHIfZa0YfQ==", "license": "GPL-3.0" }, "node_modules/@stacks/common": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-7.0.2.tgz", - "integrity": "sha512-+RSecHdkxOtswmE4tDDoZlYEuULpnTQVeDIG5eZ32opK8cFxf4EugAcK9CsIsHx/Se1yTEaQ21WGATmJGK84lQ==", - "license": "MIT" - }, - "node_modules/@stacks/connect": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@stacks/connect/-/connect-7.2.0.tgz", - "integrity": "sha512-3Y4bO31yCp0Cmf/W+fYxjRbONNK6Rb5ZaRxqfqgO+wKxHrYr1VoZobv109HF/z14a4zCECvvfqXVU6sls+FCXQ==", - "license": "MIT", - "dependencies": { - "@stacks/auth": "^6.1.1", - "@stacks/connect-ui": "6.0.1", - "@stacks/network": "^6.1.1", - "@stacks/profile": "^6.1.1", - "@stacks/transactions": "^6.1.1", - "jsontokens": "^4.0.1", - "url": "^0.11.0" - } - }, - "node_modules/@stacks/connect-ui": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@stacks/connect-ui/-/connect-ui-6.0.1.tgz", - "integrity": "sha512-DOB2UdwLJAznHfsOmloTzK7JDIfxwUq+GqEH6z0snxA3Gsu2aernlLhwUW1QLFXQtPw/fUp1ty+re71qHUc6tg==", - "license": "MIT", - "dependencies": { - "@stencil/core": "^2.17.1" - } - }, - "node_modules/@stacks/connect/node_modules/@stacks/common": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", - "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", - "license": "MIT", - "dependencies": { - "@types/bn.js": "^5.1.0", - "@types/node": "^18.0.4" + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@stacks/common/-/common-7.3.1.tgz", + "integrity": "sha512-29ANTFcSSlXnGQlgDVWg7OQ74lgQhu3x8JkeN19Q+UE/1lbQrzcctgPHG74XHjWNp8NPBqskUYA8/HLgIKuKNQ==", + "license": "MIT" + }, + "node_modules/@stacks/connect": { + "version": "7.10.2", + "resolved": "https://registry.npmjs.org/@stacks/connect/-/connect-7.10.2.tgz", + "integrity": "sha512-fQcdayBgq9XZnX4rqQxa//Gx9c0ycrmrZT9dZ01uHDlIr/ZxwU18d5A3hyYv4F7LQYQQkFr9htpVTlH0RSqWUw==", + "license": "MIT", + "dependencies": { + "@stacks/auth": "^7.0.0", + "@stacks/common": "^7.0.0", + "@stacks/connect-ui": "6.6.0", + "@stacks/network": "^7.0.0", + "@stacks/network-v6": "npm:@stacks/network@^6.16.0", + "@stacks/profile": "^7.0.0", + "@stacks/transactions": "^7.0.0", + "@stacks/transactions-v6": "npm:@stacks/transactions@^6.16.0", + "jsontokens": "^4.0.1" } }, - "node_modules/@stacks/connect/node_modules/@stacks/network": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", - "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", + "node_modules/@stacks/connect-ui": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@stacks/connect-ui/-/connect-ui-6.6.0.tgz", + "integrity": "sha512-uc22RH99umYzB94h5LiKPtGu34IBGrwUb3TfijGb2ZMudaMCiv/Fr1jjZKfQW5MRmexnbAEmGZpFlQKinCcsUA==", "license": "MIT", "dependencies": { - "@stacks/common": "^6.16.0", - "cross-fetch": "^3.1.5" + "@stencil/core": "^2.17.1" } }, - "node_modules/@stacks/connect/node_modules/@stacks/transactions": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.17.0.tgz", - "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", + "node_modules/@stacks/encryption": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-7.3.1.tgz", + "integrity": "sha512-hCY61gd4PVr5LUZKOuzWfPLmuPrIGEapd1LkMintToJ+F3R/x0T+iIJVnJf2Y1l0cJsc4Xxq/TWCBeEAfybScg==", "license": "MIT", "dependencies": { "@noble/hashes": "1.1.5", "@noble/secp256k1": "1.7.1", - "@stacks/common": "^6.16.0", - "@stacks/network": "^6.17.0", - "c32check": "^2.0.0", - "lodash.clonedeep": "^4.5.0" + "@scure/bip39": "1.1.0", + "@stacks/common": "^7.3.1", + "base64-js": "^1.5.1", + "bs58": "^5.0.0", + "ripemd160-min": "^0.0.6", + "varuint-bitcoin": "^1.1.2" } }, - "node_modules/@stacks/connect/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "node_modules/@stacks/network": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.3.1.tgz", + "integrity": "sha512-dQjhcwkz8lihSYSCUMf7OYeEh/Eh0++NebDtXbIB3pHWTvNCYEH7sxhYTB1iyunurv31/QEi0RuWdlfXK/BjeA==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "@stacks/common": "^7.3.1", + "cross-fetch": "^3.1.5" } }, - "node_modules/@stacks/connect/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/@stacks/encryption": { + "node_modules/@stacks/network-v6": { + "name": "@stacks/network", "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/encryption/-/encryption-6.17.0.tgz", - "integrity": "sha512-c0+ZOjrAiB1fDCjXO6XqHdYgpeBeMYyeH+dWahpD1VQUDor2PE5Q47qyuibWmx36rLWt1M6wlaLdeVm6HlKGzw==", + "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", + "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", "license": "MIT", "dependencies": { - "@noble/hashes": "1.1.5", - "@noble/secp256k1": "1.7.1", - "@scure/bip39": "1.1.0", "@stacks/common": "^6.16.0", - "@types/node": "^18.0.4", - "base64-js": "^1.5.1", - "bs58": "^5.0.0", - "ripemd160-min": "^0.0.6", - "varuint-bitcoin": "^1.1.2" + "cross-fetch": "^3.1.5" } }, - "node_modules/@stacks/encryption/node_modules/@stacks/common": { + "node_modules/@stacks/network-v6/node_modules/@stacks/common": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", @@ -960,7 +2171,7 @@ "@types/node": "^18.0.4" } }, - "node_modules/@stacks/encryption/node_modules/@types/node": { + "node_modules/@stacks/network-v6/node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", @@ -969,37 +2180,56 @@ "undici-types": "~5.26.4" } }, - "node_modules/@stacks/encryption/node_modules/undici-types": { + "node_modules/@stacks/network-v6/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, - "node_modules/@stacks/network": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@stacks/network/-/network-7.2.0.tgz", - "integrity": "sha512-AkLougCF2RLbK97TtISZxAhF3cE757XMXWOGKvEFWNauiQ5/bYyI9W5jZypG3yI/AyYIo04NKoFWWTnpJcn1iA==", + "node_modules/@stacks/profile": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-7.3.1.tgz", + "integrity": "sha512-fjysyN29e0mZYN3PHkZAE+6w3cfWInamDYmKLLi+wTIw0ds4YRk0CaPnMHlanVybqdIQ84yeghETNL/+o+QxEQ==", "license": "MIT", "dependencies": { - "@stacks/common": "^7.0.2", - "cross-fetch": "^3.1.5" + "@stacks/common": "^7.3.1", + "@stacks/network": "^7.3.1", + "@stacks/transactions": "^7.3.1", + "jsontokens": "^4.0.1", + "schema-inspector": "^2.0.2", + "zone-file": "^2.0.0-beta.3" } }, - "node_modules/@stacks/profile": { + "node_modules/@stacks/transactions": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.3.1.tgz", + "integrity": "sha512-ufnC1BPrOKz5b5gxxdseP3vBrFq1+qx1L6t+J/QnjXULyWdkhtS+LBEqRw2bL5qNteMvU2GhqPgFtYQPzolGbw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", + "@stacks/common": "^7.3.1", + "@stacks/network": "^7.3.1", + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" + } + }, + "node_modules/@stacks/transactions-v6": { + "name": "@stacks/transactions", "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/profile/-/profile-6.17.0.tgz", - "integrity": "sha512-EoYe0NapFc6bgA+vyCVY2sYYRHk3pbsbRnm3eaSp8y9Drfy8dBqsM10W1jjTwOn0R+IMmDT52lojdW7Pw3c7Mw==", + "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.17.0.tgz", + "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", "license": "MIT", "dependencies": { + "@noble/hashes": "1.1.5", + "@noble/secp256k1": "1.7.1", "@stacks/common": "^6.16.0", "@stacks/network": "^6.17.0", - "@stacks/transactions": "^6.17.0", - "jsontokens": "^4.0.1", - "schema-inspector": "^2.0.2", - "zone-file": "^2.0.0-beta.3" + "c32check": "^2.0.0", + "lodash.clonedeep": "^4.5.0" } }, - "node_modules/@stacks/profile/node_modules/@stacks/common": { + "node_modules/@stacks/transactions-v6/node_modules/@stacks/common": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", @@ -1009,7 +2239,7 @@ "@types/node": "^18.0.4" } }, - "node_modules/@stacks/profile/node_modules/@stacks/network": { + "node_modules/@stacks/transactions-v6/node_modules/@stacks/network": { "version": "6.17.0", "resolved": "https://registry.npmjs.org/@stacks/network/-/network-6.17.0.tgz", "integrity": "sha512-numHbfKjwco/rbkGPOEz8+FcJ2nBnS/tdJ8R422Q70h3SiA9eqk9RjSzB8p4JP8yW1SZvW+eihADHfMpBuZyfw==", @@ -1019,21 +2249,7 @@ "cross-fetch": "^3.1.5" } }, - "node_modules/@stacks/profile/node_modules/@stacks/transactions": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-6.17.0.tgz", - "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.1.5", - "@noble/secp256k1": "1.7.1", - "@stacks/common": "^6.16.0", - "@stacks/network": "^6.17.0", - "c32check": "^2.0.0", - "lodash.clonedeep": "^4.5.0" - } - }, - "node_modules/@stacks/profile/node_modules/@types/node": { + "node_modules/@stacks/transactions-v6/node_modules/@types/node": { "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", @@ -1042,26 +2258,12 @@ "undici-types": "~5.26.4" } }, - "node_modules/@stacks/profile/node_modules/undici-types": { + "node_modules/@stacks/transactions-v6/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, - "node_modules/@stacks/transactions": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/@stacks/transactions/-/transactions-7.3.0.tgz", - "integrity": "sha512-xtIktW0I0z+5VPnQM5ZfXpeTKKBY2XwqvhYZJdIGT5QQ3SvZpJVoP7aod2pms4IUfW53kTyCjyaNm6hFmgWOWw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.1.5", - "@noble/secp256k1": "1.7.1", - "@stacks/common": "^7.0.2", - "@stacks/network": "^7.2.0", - "c32check": "^2.0.0", - "lodash.clonedeep": "^4.5.0" - } - }, "node_modules/@stencil/core": { "version": "2.22.3", "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.3.tgz", @@ -1107,12 +2309,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@vitest/expect": { @@ -1316,6 +2518,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1359,35 +2567,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/camelcase": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", @@ -1414,9 +2593,9 @@ } }, "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "license": "MIT", "engines": { "node": ">= 16" @@ -1661,62 +2840,18 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1785,6 +2920,24 @@ "node": ">=12.0.0" } }, + "node_modules/fast-xml-parser": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1823,15 +2976,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1842,9 +2986,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -1853,43 +2997,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -1902,42 +3009,6 @@ "node": ">= 6" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2073,15 +3144,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2135,18 +3197,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2266,27 +3316,6 @@ "node": ">= 6" } }, - "node_modules/punycode": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2323,9 +3352,9 @@ } }, "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -2338,28 +3367,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -2398,78 +3430,6 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2547,6 +3507,18 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2650,6 +3622,12 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2664,24 +3642,11 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, - "node_modules/url": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", - "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", - "license": "MIT", - "dependencies": { - "punycode": "^1.4.1", - "qs": "^6.12.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/varuint-bitcoin": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", @@ -2696,7 +3661,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/package.json b/package.json index f3a7a26..4518c7d 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,16 @@ "init:stackflow": "node scripts/init-stackflow.js", "build:ui": "node scripts/build-ui.js", "test": "vitest run", - "test:watchtower:http": "WATCHTOWER_HTTP_INTEGRATION=1 vitest run tests/watchtower-http.integration.test.ts", + "test:stackflow-node:http": "STACKFLOW_NODE_HTTP_INTEGRATION=1 vitest run tests/watchtower-http.integration.test.ts", "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"", - "build:watchtower": "tsc -p tsconfig.server.json", - "watchtower": "npm run build:watchtower && node server/dist/index.js" + "build:stackflow-node": "tsc -p tsconfig.server.json", + "stackflow-node": "npm run build:stackflow-node && node server/dist/index.js" }, "author": "", "license": "ISC", "dependencies": { + "@aws-sdk/client-kms": "^3.993.0", "@stacks/clarinet-sdk": "^3.10.0", "@stacks/connect": "^7.2.0", "@stacks/network": "^7.2.0", diff --git a/run-with-devnet.sh b/run-with-devnet.sh index dda5f5f..42d30e4 100755 --- a/run-with-devnet.sh +++ b/run-with-devnet.sh @@ -1,13 +1,15 @@ -WATCHTOWER_HOST=0.0.0.0 \ -WATCHTOWER_PORT=8787 \ +STACKFLOW_NODE_HOST=0.0.0.0 \ +STACKFLOW_NODE_PORT=8787 \ STACKS_NETWORK=devnet \ STACKS_API_URL=http://localhost:3999 \ STACKFLOW_CONTRACTS=ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow \ -WATCHTOWER_PRINCIPALS=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ -WATCHTOWER_SIGNATURE_VERIFIER_MODE=readonly \ -WATCHTOWER_DISPUTE_EXECUTOR_MODE=auto \ -WATCHTOWER_SIGNER_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ -WATCHTOWER_PRODUCER_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ -WATCHTOWER_PRODUCER_PRINCIPAL=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ -WATCHTOWER_LOG_RAW_EVENTS=true \ -npm run watchtower +STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE=readonly \ +STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE=auto \ +STACKFLOW_NODE_DISPUTE_SIGNER_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ +STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE=kms \ +npm run stackflow-node + +# STACKFLOW_NODE_LOG_RAW_EVENTS=true \ +# STACKFLOW_NODE_PRINCIPALS=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ +# STACKFLOW_NODE_COUNTERPARTY_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ +# STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ diff --git a/server/DESIGN.md b/server/DESIGN.md index 03bd138..232d76c 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -1,8 +1,8 @@ -# Stackflow Watchtower Server Design +# Stackflow Node Server Design ## Purpose -The watchtower server protects users from stale channel closures by: +The stackflow-node server protects users from stale channel closures by: 1. Accepting and persisting the latest valid signed state for watched users. 2. Listening to Stackflow `print` events from a Stacks node observer (`POST /new_block`). @@ -12,7 +12,7 @@ The watchtower server protects users from stale channel closures by: - `src/index.ts` - HTTP API, built-in UI static file serving, and dependency wiring. -- `src/watchtower.ts` +- `src/stackflow-node.ts` - Core decision engine and state transitions. - `src/observer-parser.ts` - Normalizes observer payloads into Stackflow events. @@ -27,9 +27,8 @@ The watchtower server protects users from stale channel closures by: State is persisted in SQLite: -- Default: `server/data/watchtower-state.db` -- Config: `WATCHTOWER_DB_FILE` -- Backward-compatible alias: `WATCHTOWER_STATE_FILE` +- Default: `server/data/stackflow-node-state.db` +- Config: `STACKFLOW_NODE_DB_FILE` ### SQLite settings @@ -60,7 +59,7 @@ On startup the store configures: ### Data lifecycle - Every write updates `meta.updated_at`. -- `recent_events` is capped by `WATCHTOWER_MAX_RECENT_EVENTS` (default `500`). +- `recent_events` is capped by `STACKFLOW_NODE_MAX_RECENT_EVENTS` (default `500`). - `recent_events` is pruned after each insert. ## API @@ -76,17 +75,17 @@ On startup the store configures: - Stacks-node observer compatibility endpoints. They are accepted and ignored. - `POST /signature-states` - Off-chain state submission. -- `POST /producer/transfer` - - Producer-mode transfer signing (`action=1`). -- `POST /producer/signature-request` - - Producer-mode close/deposit/withdraw signing (`action=0|2|3`). +- `POST /counterparty/transfer` + - Counterparty-mode transfer signing (`action=1`). +- `POST /counterparty/signature-request` + - Counterparty-mode close/deposit/withdraw signing (`action=0|2|3`). - For `action=2|3`, request payload must include `amount`. -Producer-mode endpoints apply local policy before signing: +Counterparty-mode endpoints apply local policy before signing: - Reject if requested nonce is not strictly higher than latest known nonce. -- Reject if producer balance would decrease. -- For transfer requests (`action=1`), require producer balance to strictly increase +- Reject if counterparty balance would decrease. +- For transfer requests (`action=1`), require counterparty balance to strictly increase and preserve total channel balance. - Counterparty signatures are validated via on-chain read-only `verify-signature-request`, including action-aware amount checks. @@ -117,7 +116,7 @@ Producer-mode endpoints apply local policy before signing: 3. Apply contract filter: - explicit `STACKFLOW_CONTRACTS`, or - default `*.stackflow*` matcher. -4. Apply principal scope filter (`WATCHTOWER_PRINCIPALS`) if configured. +4. Apply principal scope filter (`STACKFLOW_NODE_PRINCIPALS`) if configured. 5. Record event in `recent_events`. 6. Update closure state: - open: `force-close`, `force-cancel` @@ -151,21 +150,24 @@ Deduping: ## Config -- `WATCHTOWER_HOST`, `WATCHTOWER_PORT` -- `WATCHTOWER_DB_FILE` (or alias `WATCHTOWER_STATE_FILE`) -- `WATCHTOWER_MAX_RECENT_EVENTS` +- `STACKFLOW_NODE_HOST`, `STACKFLOW_NODE_PORT` +- `STACKFLOW_NODE_DB_FILE` +- `STACKFLOW_NODE_MAX_RECENT_EVENTS` - `STACKFLOW_CONTRACTS` -- `WATCHTOWER_PRINCIPALS` (CSV allowlist, max 100) +- `STACKFLOW_NODE_PRINCIPALS` (CSV allowlist, max 100) - `STACKS_NETWORK` - `STACKS_API_URL` -- `WATCHTOWER_SIGNER_KEY` -- `WATCHTOWER_PRODUCER_KEY` -- `WATCHTOWER_PRODUCER_PRINCIPAL` -- `WATCHTOWER_PRODUCER_SIGNER_MODE` (`local-key|kms`) -- `WATCHTOWER_STACKFLOW_MESSAGE_VERSION` -- `WATCHTOWER_SIGNATURE_VERIFIER_MODE` (`readonly|accept-all|reject-all`) -- `WATCHTOWER_DISPUTE_EXECUTOR_MODE` (`auto|noop|mock`) -- `WATCHTOWER_DISPUTE_ONLY_BENEFICIAL` +- `STACKFLOW_NODE_DISPUTE_SIGNER_KEY` +- `STACKFLOW_NODE_COUNTERPARTY_KEY` +- `STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL` +- `STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE` (`local-key|kms`) +- `STACKFLOW_NODE_COUNTERPARTY_KMS_KEY_ID` +- `STACKFLOW_NODE_COUNTERPARTY_KMS_REGION` +- `STACKFLOW_NODE_COUNTERPARTY_KMS_ENDPOINT` +- `STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION` +- `STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE` (`readonly|accept-all|reject-all`) +- `STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE` (`auto|noop|mock`) +- `STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL` ## Production Notes diff --git a/server/data/watchtower-state.db b/server/data/watchtower-state.db deleted file mode 100644 index fced8eca8ce47f0fcefa138ab685e7f68c49c45e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 65536 zcmeI*%WvDr9S3m9FU8I}$^vzC2!d4v6p5QC%OQs^r)lcgisCp?{Lp4?7X`t`(B@jE zv=ZewNOF*oE!v{jUi+8y)N_H}dg(pKUWx)ec1XPy+fj^U;WfU5EQ_4saOT6`j7*BA z)>of+0ZY5(>Yf>-rP$@z$Vlwh>2xd>8{t2f`A>VA#)qFI<`WbD}o>w}d$Ui!qNUV}bw#&W6BBaw2u>R(vxs ztrDw!U*cngQDfiNnQsryUS3$s%@=d&;{1arx%5EZo4&cnZu2!~8^5}gNZq&*KfD{2 zS!Fi!1B31J=UU%Pvah_pr1Z_HbZg>8rk4uETt2s!URhguI={A=Ue0Z%=f5dFTPpA( zp5_Y0+nwyuc8u?8Wj|faZ4}dmXZ+`zCr`RL%2th4_nBkxVX!YttBl#k%yc5PoQ*f` zd%nXCJmq?N!WDzkGKyLDk#dW>q8L>`|U>Qmt6tu$KtOw<2{{4_3?ajA?WV(@_N~BhKAU*2@Qq9};&7fZ8oh;x1 z!~?1A8tM(EUVJZ>26ObU-y2SYId68hlLRpO!l|DJ=ETxJgJLyJldUw3@({!Oe7MiyR-3z-05PS@jdEMM)=&n z=)!xwsy{tirMD~fCm++1emR~<&CbSuFGU@xf0h2ZiM~$MpY~&Sp{Nt?vX>{mW43yM zL86`5jgall!b%>WF2tWV=5P$##AOHafK;R4tyv@b0yq%9qWA%`e=3exv=(lW9#82Sp`mLsHZmcfk9_BX0t<{yhrWO{%jCo=C z`I<)cr(2I6E1M7K;zB`NmWykP&o>_G;=H2Jl}8)J&r|9t+V?8cxyf*rWacu%HZ^m* zXGO8O;&4spYX;%%V>5Gpy;OQ55TP|mR1h^$QWRYjbV;L%#2=DM6+xvEQ58)jgvzqu z)_o@%-ak<|kVRr@f~pZ$u@&l4>Z*z>>BMmbNpeJ{YA&H+S&pc?wk*nmsW5?QRJN&0 z7?Y)en*k_uM7={rx)px@A5T_d{DBDq5P$##AOHafKmY;|fB*y_0D(^=(6}^qczyB| zCon$K2^t*#f1(|WVjutk2tWV=5P$##AOHafKmY)nv!fR7Y}L%EH%FcLY@y zG*NIYN4JT~1cxbMH4a=?F%`kqOiiQAA*w4nL?fE+NHVXDMHEL7T}vg*Rw>aOeo;QL zTz)0Ksc4d7%927%%d}jTsS2+bb3|2l`9ewL<=djI@@w{0NmXrKGx@f83v|`e1x*PH z7x=#e2t*_wA$atc0X9GX&&QJabJw-eAP7JJ0uX=z1Rwwb2tWV=5P-m^7HFtrhqcpu zs{ffT>mT}@5w8FL)S-a#AOHafKmY;|fB*y_009U<00ObtS-k!q-~Wfmga8B}009U< z00Izz00bZa0SKILf$;Nx9RHv1eT>#Y00Izz00bZa0SG_<0uX=z1aSP17=Qo-AOHaf zKmY;|fB*y_009V`e}U-!|6ja|@dqXdKmY;|fB*y_009U<00QSg;CHF`c-I!gZI~%go%X2*~y-W+_XWg%olR zG&;YYD=cm-Z{{DbtbVI0n;WYOxrez8acgxYuc?K_unaESTv7bUtq0{@=4a;oda3lL zS(mUPmRT}=n?=dwQBo9L6vCaZNc - parsePrincipal(principal, 'WATCHTOWER_PRINCIPALS'), + parsePrincipal(principal, 'STACKFLOW_NODE_PRINCIPALS'), ); if (principals.length > MAX_WATCHED_PRINCIPALS) { throw new Error( - `WATCHTOWER_PRINCIPALS exceeds max of ${MAX_WATCHED_PRINCIPALS} entries`, + `STACKFLOW_NODE_PRINCIPALS exceeds max of ${MAX_WATCHED_PRINCIPALS} entries`, ); } @@ -87,7 +87,7 @@ function parseSignatureVerifierMode(value: unknown): SignatureVerifierMode { } throw new Error( - 'WATCHTOWER_SIGNATURE_VERIFIER_MODE must be readonly, accept-all, or reject-all', + 'STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE must be readonly, accept-all, or reject-all', ); } @@ -98,69 +98,78 @@ function parseDisputeExecutorMode(value: unknown): DisputeExecutorMode { } throw new Error( - 'WATCHTOWER_DISPUTE_EXECUTOR_MODE must be auto, noop, or mock', + 'STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE must be auto, noop, or mock', ); } -function parseProducerSignerMode(value: unknown): ProducerSignerMode { +function parseCounterpartySignerMode(value: unknown): CounterpartySignerMode { const normalized = String(value || 'local-key').trim().toLowerCase(); if (normalized === 'local-key' || normalized === 'kms') { return normalized; } throw new Error( - 'WATCHTOWER_PRODUCER_SIGNER_MODE must be local-key or kms', + 'STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE must be local-key or kms', ); } function parseStackflowMessageVersion(value: unknown): string { const text = String(value || '0.6.0').trim(); if (text.length === 0) { - throw new Error('WATCHTOWER_STACKFLOW_MESSAGE_VERSION must not be empty'); + throw new Error('STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION must not be empty'); } if (!/^[\x20-\x7E]+$/.test(text)) { - throw new Error('WATCHTOWER_STACKFLOW_MESSAGE_VERSION must be ASCII'); + throw new Error('STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION must be ASCII'); } return text; } -export function loadConfig(env: NodeJS.ProcessEnv = process.env): WatchtowerConfig { - const dbFile = - env.WATCHTOWER_DB_FILE?.trim() || - env.WATCHTOWER_STATE_FILE?.trim() || - DEFAULT_DB_FILE; +export function loadConfig(env: NodeJS.ProcessEnv = process.env): StackflowNodeConfig { + const dbFile = env.STACKFLOW_NODE_DB_FILE?.trim() || DEFAULT_DB_FILE; + const disputeSignerKey = env.STACKFLOW_NODE_DISPUTE_SIGNER_KEY?.trim() || null; return { - host: env.WATCHTOWER_HOST?.trim() || DEFAULT_HOST, - port: parseInteger(env.WATCHTOWER_PORT, DEFAULT_PORT), + host: env.STACKFLOW_NODE_HOST?.trim() || DEFAULT_HOST, + port: parseInteger(env.STACKFLOW_NODE_PORT, DEFAULT_PORT), dbFile, maxRecentEvents: parseInteger( - env.WATCHTOWER_MAX_RECENT_EVENTS, + env.STACKFLOW_NODE_MAX_RECENT_EVENTS, DEFAULT_MAX_RECENT_EVENTS, ), - logRawEvents: parseBoolean(env.WATCHTOWER_LOG_RAW_EVENTS, false), + logRawEvents: parseBoolean(env.STACKFLOW_NODE_LOG_RAW_EVENTS, false), watchedContracts: parseCsv(env.STACKFLOW_CONTRACTS), - watchedPrincipals: parsePrincipalCsv(env.WATCHTOWER_PRINCIPALS), + watchedPrincipals: parsePrincipalCsv(env.STACKFLOW_NODE_PRINCIPALS), stacksNetwork: parseNetwork(env.STACKS_NETWORK), stacksApiUrl: env.STACKS_API_URL?.trim() || null, - signerKey: env.WATCHTOWER_SIGNER_KEY?.trim() || null, - producerKey: - env.WATCHTOWER_PRODUCER_KEY?.trim() || env.WATCHTOWER_SIGNER_KEY?.trim() || null, - producerPrincipal: env.WATCHTOWER_PRODUCER_PRINCIPAL?.trim() || null, - producerSignerMode: parseProducerSignerMode( - env.WATCHTOWER_PRODUCER_SIGNER_MODE, + disputeSignerKey, + counterpartyKey: + env.STACKFLOW_NODE_COUNTERPARTY_KEY?.trim() || + disputeSignerKey || + null, + counterpartyPrincipal: env.STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL?.trim() || null, + counterpartySignerMode: parseCounterpartySignerMode( + env.STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE, ), + counterpartyKmsKeyId: + env.STACKFLOW_NODE_COUNTERPARTY_KMS_KEY_ID?.trim() || + env.KMS_KEY_ID?.trim() || + null, + counterpartyKmsRegion: + env.STACKFLOW_NODE_COUNTERPARTY_KMS_REGION?.trim() || + env.AWS_REGION?.trim() || + null, + counterpartyKmsEndpoint: env.STACKFLOW_NODE_COUNTERPARTY_KMS_ENDPOINT?.trim() || null, stackflowMessageVersion: parseStackflowMessageVersion( - env.WATCHTOWER_STACKFLOW_MESSAGE_VERSION, + env.STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION, ), signatureVerifierMode: parseSignatureVerifierMode( - env.WATCHTOWER_SIGNATURE_VERIFIER_MODE, + env.STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE, ), disputeExecutorMode: parseDisputeExecutorMode( - env.WATCHTOWER_DISPUTE_EXECUTOR_MODE, + env.STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE, ), disputeOnlyBeneficial: parseBoolean( - env.WATCHTOWER_DISPUTE_ONLY_BENEFICIAL, + env.STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL, false, ), }; diff --git a/server/src/counterparty-service.ts b/server/src/counterparty-service.ts new file mode 100644 index 0000000..8f9d45b --- /dev/null +++ b/server/src/counterparty-service.ts @@ -0,0 +1,1258 @@ +import { createHash, createPublicKey } from 'node:crypto'; + +import { createNetwork } from '@stacks/network'; +import { + ClarityType, + bufferCV, + encodeStructuredDataBytes, + fetchCallReadOnlyFunction, + getAddressFromPrivateKey, + noneCV, + principalCV, + PubKeyEncoding, + publicKeyFromSignatureVrs, + publicKeyToAddressSingleSig, + signStructuredData, + someCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; + +import { + canonicalPipeKey, + hexToBytes, + isValidHex, + normalizeHex, + parseOptionalUInt, + parsePrincipal, + parseUInt, + splitContractId, +} from './principal-utils.js'; +import { normalizePipeId } from './observer-parser.js'; +import { describeStackflowContractError } from './signature-verifier.js'; +import type { + CounterpartySignerMode, + SignatureStateUpsertResult, + SignatureVerificationResult, + SignatureVerifierMode, + StackflowNodeConfig, +} from './types.js'; +import { + PrincipalNotWatchedError, + SignatureValidationError, + StackflowNode, +} from './stackflow-node.js'; + +const ACTION_CLOSE = '0'; +const ACTION_TRANSFER = '1'; +const ACTION_DEPOSIT = '2'; +const ACTION_WITHDRAWAL = '3'; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeContractId(input: unknown): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new CounterpartyServiceError(400, 'contractId must be a non-empty string'); + } + + const contractId = input.trim(); + try { + splitContractId(contractId); + } catch { + throw new CounterpartyServiceError(400, 'invalid contractId'); + } + return contractId; +} + +function normalizeToken(input: unknown): string | null { + if (input === null || input === undefined || input === '') { + return null; + } + + try { + return parsePrincipal(input, 'token'); + } catch (error) { + throw new CounterpartyServiceError( + 400, + error instanceof Error ? error.message : 'token must be a principal', + ); + } +} + +function normalizeHexBuff(input: unknown, bytes: number, fieldName: string): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new CounterpartyServiceError(400, `${fieldName} must be a hex string`); + } + + const value = input.trim().toLowerCase(); + if (!isValidHex(value, bytes)) { + throw new CounterpartyServiceError(400, `${fieldName} must be ${bytes} bytes of hex`); + } + + return value.startsWith('0x') ? value : `0x${value}`; +} + +function normalizeOptionalHexBuff( + input: unknown, + bytes: number, + fieldName: string, +): string | null { + if (input === null || input === undefined || input === '') { + return null; + } + + return normalizeHexBuff(input, bytes, fieldName); +} + +function normalizeBool(input: unknown, fallback: boolean): boolean { + if (input === undefined || input === null || input === '') { + return fallback; + } + + if (typeof input === 'boolean') { + return input; + } + + const normalized = String(input).trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + + throw new CounterpartyServiceError(400, 'beneficialOnly must be a boolean'); +} + +function chainIdForNetwork(network: StackflowNodeConfig['stacksNetwork']): bigint { + if (network === 'mainnet') { + return 1n; + } + return 2_147_483_648n; +} + +function senderAddressForPrincipal(principal: string): string { + if (principal.includes('.')) { + return splitContractId(principal).address; + } + return principal; +} + +function parsePrincipalField(value: unknown, fieldName: string): string { + try { + return parsePrincipal(value, fieldName); + } catch (error) { + throw new CounterpartyServiceError( + 400, + error instanceof Error + ? error.message + : `${fieldName} must be a principal string`, + ); + } +} + +function parseUIntField(value: unknown, fieldName: string): string { + try { + return parseUInt(value); + } catch { + throw new CounterpartyServiceError(400, `${fieldName} must be a uint`); + } +} + +function parseOptionalUIntField(value: unknown, fieldName: string): string | null { + try { + return parseOptionalUInt(value); + } catch { + throw new CounterpartyServiceError(400, `${fieldName} must be a uint`); + } +} + +type CounterpartyStateSource = 'onchain' | 'signature-state'; + +interface CounterpartyStateBaseline { + source: CounterpartyStateSource; + nonce: string; + nonceValue: bigint; + myBalance: string; + myBalanceValue: bigint; + theirBalance: string; + theirBalanceValue: bigint; + updatedAt: string; +} + +function parseUnsignedBigInt(value: string | null): bigint | null { + if (value === null || !/^\d+$/.test(value)) { + return null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + +function shouldReplaceBaseline( + existing: CounterpartyStateBaseline, + incoming: CounterpartyStateBaseline, +): boolean { + if (incoming.nonceValue !== existing.nonceValue) { + return incoming.nonceValue > existing.nonceValue; + } + + if (incoming.updatedAt !== existing.updatedAt) { + return incoming.updatedAt > existing.updatedAt; + } + + if (incoming.source !== existing.source) { + return incoming.source === 'onchain'; + } + + return false; +} + +interface CounterpartySigningContext { + pipeKey: ReturnType; + balance1: string; + balance2: string; + tokenArg: ReturnType | ReturnType; + secretArg: ReturnType | ReturnType; + validAfterArg: ReturnType | ReturnType; +} + +const SECP256K1_N = BigInt( + '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141', +); +const SECP256K1_HALF_N = SECP256K1_N >> 1n; + +type KmsSdkModule = { + KMSClient: new (config?: Record) => { + send(command: unknown): Promise; + }; + SignCommand: new (input: Record) => unknown; + GetPublicKeyCommand: new (input: Record) => unknown; + SigningAlgorithmSpec?: { + ECDSA_SHA_256?: string; + }; +}; + +let kmsSdkPromise: Promise | null = null; + +function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex'); +} + +function decodeBase64Url(value: string): Buffer { + return Buffer.from(value, 'base64url'); +} + +function normalizeDerInt(value: Uint8Array): Buffer { + let bytes = Buffer.from(value); + while (bytes.length > 0 && bytes[0] === 0) { + bytes = bytes.subarray(1); + } + + if (bytes.length > 32) { + throw new CounterpartyServiceError(503, 'invalid KMS signature component length'); + } + + if (bytes.length === 32) { + return bytes; + } + + const out = Buffer.alloc(32); + bytes.copy(out, 32 - bytes.length); + return out; +} + +function parseDerSignature( + derSignature: Uint8Array, +): { r: Buffer; s: Buffer } { + const bytes = Buffer.from(derSignature); + let offset = 0; + + if (bytes[offset] !== 0x30) { + throw new CounterpartyServiceError(503, 'invalid DER signature from KMS'); + } + offset += 1; + + const totalLength = bytes[offset]; + offset += 1; + if (totalLength !== bytes.length - offset) { + throw new CounterpartyServiceError(503, 'invalid DER signature length from KMS'); + } + + if (bytes[offset] !== 0x02) { + throw new CounterpartyServiceError(503, 'invalid DER signature (missing r)'); + } + offset += 1; + + const rLength = bytes[offset]; + offset += 1; + const rBytes = bytes.subarray(offset, offset + rLength); + offset += rLength; + + if (bytes[offset] !== 0x02) { + throw new CounterpartyServiceError(503, 'invalid DER signature (missing s)'); + } + offset += 1; + + const sLength = bytes[offset]; + offset += 1; + const sBytes = bytes.subarray(offset, offset + sLength); + + return { + r: normalizeDerInt(rBytes), + s: normalizeDerInt(sBytes), + }; +} + +function ensureLowS(s: Buffer): Buffer { + const sValue = BigInt(`0x${s.toString('hex')}`); + if (sValue <= SECP256K1_HALF_N) { + return s; + } + + const normalized = SECP256K1_N - sValue; + return Buffer.from(normalized.toString(16).padStart(64, '0'), 'hex'); +} + +function spkiDerToCompressedPublicKeyHex(spkiDer: Uint8Array): string { + const keyObject = createPublicKey({ + key: Buffer.from(spkiDer), + format: 'der', + type: 'spki', + }); + const jwk = keyObject.export({ format: 'jwk' }); + if ( + !jwk || + typeof jwk !== 'object' || + typeof jwk.x !== 'string' || + typeof jwk.y !== 'string' + ) { + throw new CounterpartyServiceError(503, 'invalid KMS public key format'); + } + + const x = decodeBase64Url(jwk.x); + const y = decodeBase64Url(jwk.y); + if (x.length !== 32 || y.length !== 32) { + throw new CounterpartyServiceError(503, 'invalid KMS public key coordinates'); + } + + const prefix = (y[y.length - 1] & 1) === 0 ? 0x02 : 0x03; + return Buffer.concat([Buffer.from([prefix]), x]).toString('hex'); +} + +async function loadKmsSdk(): Promise { + if (!kmsSdkPromise) { + kmsSdkPromise = (async () => { + try { + const moduleName = '@aws-sdk/client-kms'; + const mod = await import(moduleName); + if ( + !('KMSClient' in mod) || + !('SignCommand' in mod) || + !('GetPublicKeyCommand' in mod) + ) { + throw new Error('invalid aws kms sdk module'); + } + return mod as unknown as KmsSdkModule; + } catch (error) { + kmsSdkPromise = null; + throw new CounterpartyServiceError( + 503, + 'kms-sdk-not-available', + { + reason: 'kms-sdk-not-available', + details: + error instanceof Error ? error.message : 'failed to load @aws-sdk/client-kms', + }, + ); + } + })(); + } + + return kmsSdkPromise; +} + +function buildCounterpartySigningContext(request: CounterpartySignRequest): CounterpartySigningContext { + const pipeKey = canonicalPipeKey( + request.token, + request.forPrincipal, + request.withPrincipal, + ); + const balance1 = + pipeKey['principal-1'] === request.forPrincipal + ? request.myBalance + : request.theirBalance; + const balance2 = + pipeKey['principal-1'] === request.forPrincipal + ? request.theirBalance + : request.myBalance; + + const tokenArg = request.token ? someCV(principalCV(request.token)) : noneCV(); + const secretArg = request.secret + ? someCV(bufferCV(hexToBytes(request.secret))) + : noneCV(); + const validAfterArg = request.validAfter + ? someCV(uintCV(BigInt(request.validAfter))) + : noneCV(); + + return { + pipeKey, + balance1, + balance2, + tokenArg, + secretArg, + validAfterArg, + }; +} + +function buildStructuredDataPayload( + request: CounterpartySignRequest, + stackflowMessageVersion: string, + stacksNetwork: StackflowNodeConfig['stacksNetwork'], +): { message: ReturnType; domain: ReturnType } { + const context = buildCounterpartySigningContext(request); + + const message = tupleCV({ + token: context.tokenArg, + 'principal-1': principalCV(context.pipeKey['principal-1']), + 'principal-2': principalCV(context.pipeKey['principal-2']), + 'balance-1': uintCV(BigInt(context.balance1)), + 'balance-2': uintCV(BigInt(context.balance2)), + nonce: uintCV(BigInt(request.nonce)), + action: uintCV(BigInt(request.action)), + actor: principalCV(request.actor), + 'hashed-secret': context.secretArg, + 'valid-after': context.validAfterArg, + }); + + const domain = tupleCV({ + name: stringAsciiCV(request.contractId), + version: stringAsciiCV(stackflowMessageVersion), + 'chain-id': uintCV(chainIdForNetwork(stacksNetwork)), + }); + + return { message, domain }; +} + +async function verifyCounterpartyWithReadonly( + args: { + enabled: boolean; + counterpartyPrincipal: string | null; + signatureVerifierMode: SignatureVerifierMode; + network: ReturnType; + request: CounterpartySignRequest; + }, +): Promise { + const { + enabled, + counterpartyPrincipal, + signatureVerifierMode, + network, + request, + } = args; + + if (!enabled || !counterpartyPrincipal) { + return { valid: false, reason: 'counterparty signing is not configured' }; + } + + if (request.forPrincipal !== counterpartyPrincipal) { + return { + valid: false, + reason: `forPrincipal must be ${counterpartyPrincipal}`, + }; + } + + if (signatureVerifierMode === 'accept-all') { + return { valid: true, reason: null }; + } + if (signatureVerifierMode === 'reject-all') { + return { valid: false, reason: 'invalid-signature' }; + } + + const context = buildCounterpartySigningContext(request); + const contract = splitContractId(request.contractId); + const response = await fetchCallReadOnlyFunction({ + network, + senderAddress: senderAddressForPrincipal(counterpartyPrincipal), + contractAddress: contract.address, + contractName: contract.name, + functionName: 'verify-signature-request', + functionArgs: [ + bufferCV(hexToBytes(request.theirSignature)), + principalCV(request.withPrincipal), + tupleCV({ + token: context.tokenArg, + 'principal-1': principalCV(context.pipeKey['principal-1']), + 'principal-2': principalCV(context.pipeKey['principal-2']), + }), + uintCV(BigInt(context.balance1)), + uintCV(BigInt(context.balance2)), + uintCV(BigInt(request.nonce)), + uintCV(BigInt(request.action)), + principalCV(request.actor), + context.secretArg, + context.validAfterArg, + uintCV(BigInt(request.amount)), + ], + }); + + if (response.type === ClarityType.ResponseErr) { + if (response.value.type === ClarityType.UInt) { + return { + valid: false, + reason: describeStackflowContractError(response.value.value), + }; + } + return { valid: false, reason: 'contract error' }; + } + + if (response.type !== ClarityType.ResponseOk) { + return { valid: false, reason: 'unexpected-readonly-response' }; + } + + return { valid: true, reason: null }; +} + +export interface CounterpartySignRequest { + contractId: string; + forPrincipal: string; + withPrincipal: string; + token: string | null; + amount: string; + myBalance: string; + theirBalance: string; + theirSignature: string; + nonce: string; + action: string; + actor: string; + secret: string | null; + validAfter: string | null; + beneficialOnly: boolean; +} + +interface ParseCounterpartySignRequestOptions { + counterpartyPrincipal: string; + allowedActions: Set; + defaultAction: string | null; +} + +function parseCounterpartySignRequest( + input: unknown, + options: ParseCounterpartySignRequestOptions, +): CounterpartySignRequest { + if (!isRecord(input)) { + throw new CounterpartyServiceError(400, 'payload must be an object'); + } + + const data = input; + const forPrincipalInput = data.forPrincipal; + if (forPrincipalInput !== undefined && forPrincipalInput !== null && forPrincipalInput !== '') { + const parsedForPrincipal = parsePrincipalField(forPrincipalInput, 'forPrincipal'); + if (parsedForPrincipal !== options.counterpartyPrincipal) { + throw new CounterpartyServiceError( + 400, + `forPrincipal must match counterparty principal ${options.counterpartyPrincipal}`, + ); + } + } + + const actionInput = + data.action !== undefined && data.action !== null && data.action !== '' + ? data.action + : options.defaultAction; + if (actionInput === null) { + throw new CounterpartyServiceError(400, 'action is required'); + } + + const action = parseUIntField(actionInput, 'action'); + if (!options.allowedActions.has(action)) { + throw new CounterpartyServiceError( + 400, + `action ${action} is not allowed for this endpoint`, + ); + } + + const amount = + action === ACTION_DEPOSIT || action === ACTION_WITHDRAWAL + ? parseUIntField(data.amount, 'amount') + : parseOptionalUIntField(data.amount, 'amount') || '0'; + + return { + contractId: normalizeContractId(data.contractId), + forPrincipal: options.counterpartyPrincipal, + withPrincipal: parsePrincipalField(data.withPrincipal, 'withPrincipal'), + token: normalizeToken(data.token), + amount, + myBalance: parseUIntField(data.myBalance, 'myBalance'), + theirBalance: parseUIntField(data.theirBalance, 'theirBalance'), + theirSignature: normalizeHexBuff( + data.theirSignature ?? data.counterpartySignature, + 65, + 'theirSignature', + ), + nonce: parseUIntField(data.nonce, 'nonce'), + action, + actor: parsePrincipalField(data.actor, 'actor'), + secret: normalizeOptionalHexBuff(data.secret, 32, 'secret'), + validAfter: parseOptionalUIntField(data.validAfter, 'validAfter'), + beneficialOnly: normalizeBool(data.beneficialOnly, false), + }; +} + +export interface CounterpartySigner { + readonly enabled: boolean; + readonly counterpartyPrincipal: string | null; + readonly signerAddress: string | null; + ensureReady(): Promise; + verifyCounterpartySignature( + request: CounterpartySignRequest, + ): Promise; + signMySignature(request: CounterpartySignRequest): Promise; +} + +export class CounterpartyStateSigner implements CounterpartySigner { + readonly enabled: boolean; + + readonly counterpartyPrincipal: string | null; + + readonly signerAddress: string | null; + + private readonly counterpartyKey: string | null; + + private readonly network: ReturnType; + + private readonly signatureVerifierMode: SignatureVerifierMode; + + private readonly stackflowMessageVersion: string; + + private readonly stacksNetwork: StackflowNodeConfig['stacksNetwork']; + + constructor( + config: Pick< + StackflowNodeConfig, + | 'stacksNetwork' + | 'stacksApiUrl' + | 'signatureVerifierMode' + | 'counterpartyKey' + | 'counterpartyPrincipal' + | 'stackflowMessageVersion' + >, + ) { + this.network = createNetwork({ + network: config.stacksNetwork, + client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, + }); + + this.signatureVerifierMode = config.signatureVerifierMode; + this.stackflowMessageVersion = config.stackflowMessageVersion; + this.stacksNetwork = config.stacksNetwork; + this.counterpartyKey = config.counterpartyKey + ? normalizeHex(config.counterpartyKey).slice(2) + : null; + this.signerAddress = this.counterpartyKey + ? getAddressFromPrivateKey(this.counterpartyKey, this.network) + : null; + this.enabled = Boolean(this.counterpartyKey); + + if (!this.enabled) { + this.counterpartyPrincipal = null; + return; + } + + if (config.counterpartyPrincipal?.trim()) { + const parsedCounterpartyPrincipal = parsePrincipal( + config.counterpartyPrincipal, + 'STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL', + ); + if ( + !parsedCounterpartyPrincipal.includes('.') && + parsedCounterpartyPrincipal !== this.signerAddress + ) { + throw new Error( + `STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL (${parsedCounterpartyPrincipal}) does not match counterparty key address (${this.signerAddress})`, + ); + } + this.counterpartyPrincipal = parsedCounterpartyPrincipal; + return; + } + + this.counterpartyPrincipal = this.signerAddress; + } + + async verifyCounterpartySignature( + request: CounterpartySignRequest, + ): Promise { + return verifyCounterpartyWithReadonly({ + enabled: this.enabled, + counterpartyPrincipal: this.counterpartyPrincipal, + signatureVerifierMode: this.signatureVerifierMode, + network: this.network, + request, + }); + } + + async ensureReady(): Promise {} + + async signMySignature(request: CounterpartySignRequest): Promise { + if (!this.enabled || !this.counterpartyKey || !this.counterpartyPrincipal) { + throw new CounterpartyServiceError(503, 'counterparty signing is not configured'); + } + + const { message, domain } = buildStructuredDataPayload( + request, + this.stackflowMessageVersion, + this.stacksNetwork, + ); + + const signature = signStructuredData({ + message, + domain, + privateKey: this.counterpartyKey, + }); + return normalizeHex(signature); + } +} + +class KmsCounterpartySigner implements CounterpartySigner { + readonly enabled: boolean; + + private readonly network: ReturnType; + + private readonly signatureVerifierMode: SignatureVerifierMode; + + private readonly stackflowMessageVersion: string; + + private readonly stacksNetwork: StackflowNodeConfig['stacksNetwork']; + + private readonly kmsKeyId: string | null; + + private readonly kmsRegion: string | null; + + private readonly kmsEndpoint: string | null; + + private readonly configuredCounterpartyPrincipal: string | null; + + private kmsClient: { + send(command: unknown): Promise; + } | null = null; + + private kmsPublicKeyHex: string | null = null; + + private readyPromise: Promise | null = null; + + private ready = false; + + private mutableSignerAddress: string | null = null; + + private mutableCounterpartyPrincipal: string | null = null; + + constructor( + config: Pick< + StackflowNodeConfig, + | 'stacksNetwork' + | 'stacksApiUrl' + | 'signatureVerifierMode' + | 'counterpartyPrincipal' + | 'stackflowMessageVersion' + | 'counterpartyKmsKeyId' + | 'counterpartyKmsRegion' + | 'counterpartyKmsEndpoint' + >, + ) { + this.network = createNetwork({ + network: config.stacksNetwork, + client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, + }); + this.signatureVerifierMode = config.signatureVerifierMode; + this.stackflowMessageVersion = config.stackflowMessageVersion; + this.stacksNetwork = config.stacksNetwork; + this.kmsKeyId = config.counterpartyKmsKeyId?.trim() || null; + this.kmsRegion = config.counterpartyKmsRegion?.trim() || null; + this.kmsEndpoint = config.counterpartyKmsEndpoint?.trim() || null; + this.enabled = Boolean(this.kmsKeyId); + this.configuredCounterpartyPrincipal = config.counterpartyPrincipal?.trim() + ? parsePrincipal(config.counterpartyPrincipal, 'STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL') + : null; + + } + + get signerAddress(): string | null { + return this.mutableSignerAddress; + } + + get counterpartyPrincipal(): string | null { + return this.mutableCounterpartyPrincipal; + } + + async ensureReady(): Promise { + if (!this.enabled) { + return; + } + if (this.ready) { + return; + } + + if (!this.readyPromise) { + this.readyPromise = this.initialize(); + } + + await this.readyPromise; + } + + async verifyCounterpartySignature( + request: CounterpartySignRequest, + ): Promise { + await this.ensureReady(); + + return verifyCounterpartyWithReadonly({ + enabled: this.enabled, + counterpartyPrincipal: this.counterpartyPrincipal, + signatureVerifierMode: this.signatureVerifierMode, + network: this.network, + request, + }); + } + + async signMySignature(request: CounterpartySignRequest): Promise { + await this.ensureReady(); + if (!this.enabled || !this.kmsKeyId || !this.kmsClient || !this.kmsPublicKeyHex) { + throw new CounterpartyServiceError(503, 'counterparty signing is not configured'); + } + + const { message, domain } = buildStructuredDataPayload( + request, + this.stackflowMessageVersion, + this.stacksNetwork, + ); + const encoded = encodeStructuredDataBytes({ message, domain }); + const digest = createHash('sha256').update(Buffer.from(encoded)).digest(); + + const sdk = await loadKmsSdk(); + const signCommand = new sdk.SignCommand({ + KeyId: this.kmsKeyId, + Message: digest, + MessageType: 'DIGEST', + SigningAlgorithm: sdk.SigningAlgorithmSpec?.ECDSA_SHA_256 || 'ECDSA_SHA_256', + }); + const signResponse = await this.kmsClient.send(signCommand) as { + Signature?: Uint8Array; + }; + if (!signResponse?.Signature) { + throw new CounterpartyServiceError(503, 'kms-signature-not-returned'); + } + + const { r, s } = parseDerSignature(signResponse.Signature); + const lowS = ensureLowS(s); + + const messageHashHex = toHex(digest); + const rHex = r.toString('hex'); + const sHex = lowS.toString('hex'); + + let recoveryId: number | null = null; + for (let candidate = 0; candidate <= 3; candidate += 1) { + const vrs = `${candidate.toString(16).padStart(2, '0')}${rHex}${sHex}`; + try { + const recovered = publicKeyFromSignatureVrs( + messageHashHex, + vrs, + PubKeyEncoding.Compressed, + ); + if (normalizeHex(recovered).slice(2) === this.kmsPublicKeyHex) { + recoveryId = candidate; + break; + } + } catch { + // continue + } + } + + if (recoveryId === null) { + throw new CounterpartyServiceError(503, 'kms-signature-recovery-failed'); + } + + const rsv = `${rHex}${sHex}${recoveryId.toString(16).padStart(2, '0')}`; + return normalizeHex(`0x${rsv}`); + } + + private async initialize(): Promise { + if (!this.kmsKeyId) { + return; + } + + const sdk = await loadKmsSdk(); + const clientConfig: Record = {}; + if (this.kmsRegion) { + clientConfig.region = this.kmsRegion; + } + if (this.kmsEndpoint) { + clientConfig.endpoint = this.kmsEndpoint; + } + + this.kmsClient = new sdk.KMSClient(clientConfig); + const getPublicKeyCommand = new sdk.GetPublicKeyCommand({ + KeyId: this.kmsKeyId, + }); + const publicKeyResponse = await this.kmsClient.send(getPublicKeyCommand) as { + PublicKey?: Uint8Array; + }; + if (!publicKeyResponse?.PublicKey) { + throw new CounterpartyServiceError(503, 'kms-public-key-not-returned'); + } + + this.kmsPublicKeyHex = spkiDerToCompressedPublicKeyHex(publicKeyResponse.PublicKey); + const signerAddress = publicKeyToAddressSingleSig(this.kmsPublicKeyHex, this.network); + this.mutableSignerAddress = signerAddress; + + if (this.configuredCounterpartyPrincipal) { + if ( + !this.configuredCounterpartyPrincipal.includes('.') && + this.configuredCounterpartyPrincipal !== signerAddress + ) { + throw new Error( + `STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL (${this.configuredCounterpartyPrincipal}) does not match kms key address (${signerAddress})`, + ); + } + this.mutableCounterpartyPrincipal = this.configuredCounterpartyPrincipal; + } else { + this.mutableCounterpartyPrincipal = signerAddress; + } + + this.ready = true; + } +} + +class UnsupportedCounterpartySigner implements CounterpartySigner { + readonly enabled = false; + + readonly counterpartyPrincipal = null; + + readonly signerAddress = null; + + private readonly reason: string; + + constructor(reason: string) { + this.reason = reason; + } + + async ensureReady(): Promise {} + + async verifyCounterpartySignature( + _request: CounterpartySignRequest, + ): Promise { + return { + valid: false, + reason: this.reason, + }; + } + + async signMySignature(_request: CounterpartySignRequest): Promise { + throw new CounterpartyServiceError(503, this.reason); + } +} + +export function createCounterpartySigner( + config: Pick< + StackflowNodeConfig, + | 'stacksNetwork' + | 'stacksApiUrl' + | 'signatureVerifierMode' + | 'counterpartyKey' + | 'counterpartyPrincipal' + | 'counterpartySignerMode' + | 'stackflowMessageVersion' + | 'counterpartyKmsKeyId' + | 'counterpartyKmsRegion' + | 'counterpartyKmsEndpoint' + >, +): CounterpartySigner { + const mode = (config.counterpartySignerMode || 'local-key') as CounterpartySignerMode; + + if (mode === 'kms') { + if (!config.counterpartyKmsKeyId) { + return new UnsupportedCounterpartySigner( + 'STACKFLOW_NODE_COUNTERPARTY_KMS_KEY_ID is required for kms signer mode', + ); + } + return new KmsCounterpartySigner(config); + } + + return new CounterpartyStateSigner(config); +} + +export interface CounterpartySignResult { + request: CounterpartySignRequest; + mySignature: string; + upsert: SignatureStateUpsertResult; +} + +export class CounterpartyService { + private readonly stackflowNode: StackflowNode; + + private readonly signer: CounterpartySigner; + + constructor({ + stackflowNode, + signer, + }: { + stackflowNode: StackflowNode; + signer: CounterpartySigner; + }) { + this.stackflowNode = stackflowNode; + this.signer = signer; + } + + get enabled(): boolean { + return this.signer.enabled; + } + + get counterpartyPrincipal(): string | null { + return this.signer.counterpartyPrincipal; + } + + async signTransfer(payload: unknown): Promise { + return this.signState(payload, new Set([ACTION_TRANSFER]), ACTION_TRANSFER); + } + + async signSignatureRequest(payload: unknown): Promise { + return this.signState( + payload, + new Set([ACTION_CLOSE, ACTION_DEPOSIT, ACTION_WITHDRAWAL]), + null, + ); + } + + private resolveCurrentBaseline( + request: CounterpartySignRequest, + ): CounterpartyStateBaseline | null { + const pipeKey = canonicalPipeKey( + request.token, + request.forPrincipal, + request.withPrincipal, + ); + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + return null; + } + + const status = this.stackflowNode.status(); + let best: CounterpartyStateBaseline | null = null; + + const consider = (candidate: CounterpartyStateBaseline): void => { + if (!best || shouldReplaceBaseline(best, candidate)) { + best = candidate; + } + }; + + for (const observed of status.observedPipes) { + if (observed.contractId !== request.contractId || observed.pipeId !== pipeId) { + continue; + } + + const principal1IsCounterparty = observed.pipeKey['principal-1'] === request.forPrincipal; + const myBalance = principal1IsCounterparty ? observed.balance1 : observed.balance2; + const theirBalance = principal1IsCounterparty ? observed.balance2 : observed.balance1; + const nonceValue = parseUnsignedBigInt(observed.nonce); + const myBalanceValue = parseUnsignedBigInt(myBalance); + const theirBalanceValue = parseUnsignedBigInt(theirBalance); + if ( + nonceValue === null || + myBalanceValue === null || + theirBalanceValue === null + ) { + continue; + } + + consider({ + source: 'onchain', + nonce: observed.nonce as string, + nonceValue, + myBalance: myBalance as string, + myBalanceValue, + theirBalance: theirBalance as string, + theirBalanceValue, + updatedAt: observed.updatedAt, + }); + } + + for (const signature of status.signatureStates) { + if ( + signature.contractId !== request.contractId || + signature.pipeId !== pipeId || + signature.forPrincipal !== request.forPrincipal + ) { + continue; + } + + const nonceValue = parseUnsignedBigInt(signature.nonce); + const myBalanceValue = parseUnsignedBigInt(signature.myBalance); + const theirBalanceValue = parseUnsignedBigInt(signature.theirBalance); + if ( + nonceValue === null || + myBalanceValue === null || + theirBalanceValue === null + ) { + continue; + } + + consider({ + source: 'signature-state', + nonce: signature.nonce, + nonceValue, + myBalance: signature.myBalance, + myBalanceValue, + theirBalance: signature.theirBalance, + theirBalanceValue, + updatedAt: signature.updatedAt, + }); + } + + return best; + } + + private enforceSigningPolicy(request: CounterpartySignRequest): void { + const baseline = this.resolveCurrentBaseline(request); + if (!baseline) { + throw new CounterpartyServiceError(409, 'unknown-pipe-state', { + reason: 'unknown-pipe-state', + }); + } + + const incomingNonce = BigInt(request.nonce); + if (incomingNonce <= baseline.nonceValue) { + throw new CounterpartyServiceError(409, 'nonce-too-low', { + reason: 'nonce-too-low', + incomingNonce: request.nonce, + existingNonce: baseline.nonce, + state: { + source: baseline.source, + nonce: baseline.nonce, + myBalance: baseline.myBalance, + theirBalance: baseline.theirBalance, + updatedAt: baseline.updatedAt, + }, + }); + } + + const requestedMyBalance = BigInt(request.myBalance); + const requestedTheirBalance = BigInt(request.theirBalance); + + if (requestedMyBalance < baseline.myBalanceValue) { + throw new CounterpartyServiceError(403, 'counterparty-balance-decrease-not-allowed', { + reason: 'counterparty-balance-decrease', + currentMyBalance: baseline.myBalance, + requestedMyBalance: request.myBalance, + }); + } + + if (request.action === ACTION_TRANSFER) { + const currentTotal = baseline.myBalanceValue + baseline.theirBalanceValue; + const requestedTotal = requestedMyBalance + requestedTheirBalance; + if (requestedTotal !== currentTotal) { + throw new CounterpartyServiceError(403, 'invalid-transfer-total', { + reason: 'invalid-transfer-total', + currentTotal: currentTotal.toString(10), + requestedTotal: requestedTotal.toString(10), + }); + } + + if (requestedMyBalance <= baseline.myBalanceValue) { + throw new CounterpartyServiceError(403, 'transfer-not-beneficial-for-counterparty', { + reason: 'transfer-not-beneficial', + currentMyBalance: baseline.myBalance, + requestedMyBalance: request.myBalance, + }); + } + } + } + + private async signState( + payload: unknown, + allowedActions: Set, + defaultAction: string | null, + ): Promise { + await this.signer.ensureReady(); + + if (!this.signer.counterpartyPrincipal) { + throw new CounterpartyServiceError(503, 'counterparty signing is not configured'); + } + + const request = parseCounterpartySignRequest(payload, { + counterpartyPrincipal: this.signer.counterpartyPrincipal, + allowedActions, + defaultAction, + }); + + this.enforceSigningPolicy(request); + + const verification = await this.signer.verifyCounterpartySignature(request); + if (!verification.valid) { + throw new CounterpartyServiceError( + 401, + verification.reason || 'counterparty signature invalid', + ); + } + + const mySignature = await this.signer.signMySignature(request); + + try { + const upsert = await this.stackflowNode.upsertSignatureState({ + contractId: request.contractId, + forPrincipal: request.forPrincipal, + withPrincipal: request.withPrincipal, + token: request.token, + amount: request.amount, + myBalance: request.myBalance, + theirBalance: request.theirBalance, + mySignature, + theirSignature: request.theirSignature, + nonce: request.nonce, + action: request.action, + actor: request.actor, + secret: request.secret, + validAfter: request.validAfter, + beneficialOnly: request.beneficialOnly, + }, { + skipVerification: true, + }); + + return { + request, + mySignature, + upsert, + }; + } catch (error) { + if (error instanceof SignatureValidationError) { + throw new CounterpartyServiceError(401, error.message); + } + + if (error instanceof PrincipalNotWatchedError) { + throw new CounterpartyServiceError(403, error.message); + } + + throw error; + } + } +} + +export class CounterpartyServiceError extends Error { + readonly statusCode: number; + + readonly details: Record | null; + + constructor( + statusCode: number, + message: string, + details: Record | null = null, + ) { + super(message); + this.name = 'CounterpartyServiceError'; + this.statusCode = statusCode; + this.details = details; + } +} diff --git a/server/src/dispute-executor.ts b/server/src/dispute-executor.ts index 4a82436..d1d2438 100644 --- a/server/src/dispute-executor.ts +++ b/server/src/dispute-executor.ts @@ -18,7 +18,7 @@ import type { SignatureStateRecord, StackflowPrintEvent, SubmitDisputeResult, - WatchtowerConfig, + StackflowNodeConfig, } from './types.js'; function normalizePrivateKey(input: string): string { @@ -37,19 +37,19 @@ export class StacksDisputeExecutor implements DisputeExecutor { private readonly network: ReturnType; - private readonly signerKey: string | null; + private readonly disputeSignerKey: string | null; - constructor(config: Pick) { + constructor(config: Pick) { this.network = createNetwork({ network: config.stacksNetwork, client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, }); - this.signerKey = config.signerKey ? normalizePrivateKey(config.signerKey) : null; + this.disputeSignerKey = config.disputeSignerKey ? normalizePrivateKey(config.disputeSignerKey) : null; - this.enabled = Boolean(this.signerKey); - this.signerAddress = this.signerKey - ? getAddressFromPrivateKey(this.signerKey, this.network) + this.enabled = Boolean(this.disputeSignerKey); + this.signerAddress = this.disputeSignerKey + ? getAddressFromPrivateKey(this.disputeSignerKey, this.network) : null; } @@ -60,8 +60,8 @@ export class StacksDisputeExecutor implements DisputeExecutor { closure: ClosureRecord; triggerEvent: StackflowPrintEvent; }): Promise { - if (!this.signerKey) { - throw new Error('watchtower signer key not configured'); + if (!this.disputeSignerKey) { + throw new Error('stackflow-node dispute signer key not configured'); } const contract = parseContractPrincipal(signatureState.contractId); @@ -80,7 +80,7 @@ export class StacksDisputeExecutor implements DisputeExecutor { const tx = await makeContractCall({ network: this.network, - senderKey: this.signerKey, + senderKey: this.disputeSignerKey, contractAddress: contract.address, contractName: contract.name, functionName: 'dispute-closure-for', diff --git a/server/src/index.ts b/server/src/index.ts index 7beaf28..2f346f4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,23 +17,23 @@ import { RejectAllSignatureVerifier, } from './signature-verifier.js'; import { - createProducerSigner, - ProducerService, - ProducerServiceError, -} from './producer-service.js'; + createCounterpartySigner, + CounterpartyService, + CounterpartyServiceError, +} from './counterparty-service.js'; import { SqliteStateStore } from './state-store.js'; import { canonicalPipeKey } from './principal-utils.js'; import type { DisputeExecutor, PipeKey, SignatureVerifier, - WatchtowerStatus, + StackflowNodeStatus, } from './types.js'; import { PrincipalNotWatchedError, SignatureValidationError, - Watchtower, -} from './watchtower.js'; + StackflowNode, +} from './stackflow-node.js'; const MAX_BODY_BYTES = 5 * 1024 * 1024; const UI_ROOT = path.resolve(process.cwd(), 'server/ui'); @@ -365,7 +365,7 @@ function shouldReplacePipe(existing: MergedPipeRecord, incoming: MergedPipeRecor } function mergeAuthoritativePipes( - status: WatchtowerStatus, + status: StackflowNodeStatus, principal: string | null, ): MergedPipeRecord[] { const records = new Map(); @@ -473,24 +473,24 @@ async function maybeServeUi( } function createHandler({ - watchtower, - producerService, + stackflowNode, + counterpartyService, startedAt, disputeEnabled, signerAddress, - producerEnabled, - producerPrincipal, + counterpartyEnabled, + counterpartyPrincipal, stacksNetwork, watchedContracts, logRawEvents, }: { - watchtower: Watchtower; - producerService: ProducerService; + stackflowNode: StackflowNode; + counterpartyService: CounterpartyService; startedAt: string; disputeEnabled: boolean; signerAddress: string | null; - producerEnabled: boolean; - producerPrincipal: string | null; + counterpartyEnabled: boolean; + counterpartyPrincipal: string | null; stacksNetwork: 'mainnet' | 'testnet' | 'devnet' | 'mocknet'; watchedContracts: string[]; logRawEvents: boolean; @@ -510,7 +510,7 @@ function createHandler({ } if (method === 'GET' && url.pathname === '/health') { - const status = watchtower.status(); + const status = stackflowNode.status(); writeJson(response, 200, { ok: true, @@ -521,15 +521,15 @@ function createHandler({ signatureStates: status.signatureStates.length, disputeEnabled, signerAddress, - producerEnabled, - producerPrincipal, + counterpartyEnabled, + counterpartyPrincipal, stacksNetwork, }); return; } if (method === 'GET' && url.pathname === '/closures') { - const status = watchtower.status(); + const status = stackflowNode.status(); writeJson(response, 200, { ok: true, closures: status.activeClosures, @@ -538,7 +538,7 @@ function createHandler({ } if (method === 'GET' && url.pathname === '/signature-states') { - const status = watchtower.status(); + const status = stackflowNode.status(); const limit = parseLimit(url); writeJson(response, 200, { @@ -549,7 +549,7 @@ function createHandler({ } if (method === 'GET' && url.pathname === '/pipes') { - const status = watchtower.status(); + const status = stackflowNode.status(); const limit = parseLimit(url); const principal = url.searchParams.get('principal')?.trim() || null; const pipes = mergeAuthoritativePipes(status, principal); @@ -562,7 +562,7 @@ function createHandler({ } if (method === 'GET' && url.pathname === '/dispute-attempts') { - const status = watchtower.status(); + const status = stackflowNode.status(); const limit = parseLimit(url); writeJson(response, 200, { @@ -573,7 +573,7 @@ function createHandler({ } if (method === 'GET' && url.pathname === '/events') { - const status = watchtower.status(); + const status = stackflowNode.status(); const limit = parseLimit(url); writeJson(response, 200, { ok: true, @@ -585,7 +585,7 @@ function createHandler({ if (method === 'POST' && url.pathname === '/signature-states') { try { const payload = await readJsonBody(request); - const result = await watchtower.upsertSignatureState(payload); + const result = await stackflowNode.upsertSignatureState(payload); if (!result.stored && result.reason === 'nonce-too-low') { const incomingNonce = @@ -597,7 +597,7 @@ function createHandler({ : null; console.warn( - `[watchtower] /signature-states rejected status=409 reason=nonce-too-low incomingNonce=${ + `[stackflow-node] /signature-states rejected status=409 reason=nonce-too-low incomingNonce=${ incomingNonce ?? '-' } existingNonce=${result.state.nonce} stateId=${result.state.stateId}`, ); @@ -619,7 +619,7 @@ function createHandler({ } catch (error) { if (error instanceof SignatureValidationError) { console.warn( - `[watchtower] /signature-states rejected status=401 error=${error.message}`, + `[stackflow-node] /signature-states rejected status=401 error=${error.message}`, ); writeJson(response, 401, { ok: false, @@ -630,7 +630,7 @@ function createHandler({ if (error instanceof PrincipalNotWatchedError) { console.warn( - `[watchtower] /signature-states rejected status=403 error=${error.message}`, + `[stackflow-node] /signature-states rejected status=403 error=${error.message}`, ); writeJson(response, 403, { ok: false, @@ -640,7 +640,7 @@ function createHandler({ } console.warn( - `[watchtower] /signature-states rejected status=400 error=${ + `[stackflow-node] /signature-states rejected status=400 error=${ error instanceof Error ? error.message : 'failed to store signature state' }`, ); @@ -655,14 +655,14 @@ function createHandler({ return; } - if (method === 'POST' && url.pathname === '/producer/transfer') { + if (method === 'POST' && url.pathname === '/counterparty/transfer') { try { const payload = await readJsonBody(request); - const result = await producerService.signTransfer(payload); + const result = await counterpartyService.signTransfer(payload); if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { console.warn( - `[watchtower] /producer/transfer rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, + `[stackflow-node] /counterparty/transfer rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, ); writeJson(response, 409, { ok: false, @@ -677,7 +677,7 @@ function createHandler({ writeJson(response, 200, { ok: true, - producerPrincipal: result.request.forPrincipal, + counterpartyPrincipal: result.request.forPrincipal, withPrincipal: result.request.withPrincipal, token: result.request.token, amount: result.request.amount, @@ -693,9 +693,9 @@ function createHandler({ reason: result.upsert.reason, }); } catch (error) { - if (error instanceof ProducerServiceError) { + if (error instanceof CounterpartyServiceError) { console.warn( - `[watchtower] /producer/transfer rejected status=${error.statusCode} error=${error.message}`, + `[stackflow-node] /counterparty/transfer rejected status=${error.statusCode} error=${error.message}`, ); const details = error.details && typeof error.details === 'object' @@ -710,7 +710,7 @@ function createHandler({ } console.error( - `[watchtower] /producer/transfer error: ${ + `[stackflow-node] /counterparty/transfer error: ${ error instanceof Error ? error.message : 'failed to sign transfer' }`, ); @@ -723,14 +723,14 @@ function createHandler({ return; } - if (method === 'POST' && url.pathname === '/producer/signature-request') { + if (method === 'POST' && url.pathname === '/counterparty/signature-request') { try { const payload = await readJsonBody(request); - const result = await producerService.signSignatureRequest(payload); + const result = await counterpartyService.signSignatureRequest(payload); if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { console.warn( - `[watchtower] /producer/signature-request rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, + `[stackflow-node] /counterparty/signature-request rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, ); writeJson(response, 409, { ok: false, @@ -745,7 +745,7 @@ function createHandler({ writeJson(response, 200, { ok: true, - producerPrincipal: result.request.forPrincipal, + counterpartyPrincipal: result.request.forPrincipal, withPrincipal: result.request.withPrincipal, token: result.request.token, amount: result.request.amount, @@ -761,9 +761,9 @@ function createHandler({ reason: result.upsert.reason, }); } catch (error) { - if (error instanceof ProducerServiceError) { + if (error instanceof CounterpartyServiceError) { console.warn( - `[watchtower] /producer/signature-request rejected status=${error.statusCode} error=${error.message}`, + `[stackflow-node] /counterparty/signature-request rejected status=${error.statusCode} error=${error.message}`, ); const details = error.details && typeof error.details === 'object' @@ -778,7 +778,7 @@ function createHandler({ } console.error( - `[watchtower] /producer/signature-request error: ${ + `[stackflow-node] /counterparty/signature-request error: ${ error instanceof Error ? error.message : 'failed to sign request' }`, ); @@ -795,7 +795,7 @@ function createHandler({ try { const payload = await readJsonBody(request); console.log( - `[watchtower] /new_block received ${summarizeNewBlockPayload(payload)}`, + `[stackflow-node] /new_block received ${summarizeNewBlockPayload(payload)}`, ); if (logRawEvents) { const samples = extractRawStackflowPrintEventSamples( @@ -803,22 +803,22 @@ function createHandler({ watchedContracts, ); console.log( - `[watchtower] /new_block raw stackflow events count=${samples.length}`, + `[stackflow-node] /new_block raw stackflow events count=${samples.length}`, ); for (const [index, sample] of samples.entries()) { console.log( - `[watchtower] /new_block raw stackflow event[${index}] ${stringifyForLog(sample)}`, + `[stackflow-node] /new_block raw stackflow event[${index}] ${stringifyForLog(sample)}`, ); } } - const result = await watchtower.ingest(payload, url.pathname); + const result = await stackflowNode.ingest(payload, url.pathname); console.log( - `[watchtower] /new_block processed observedEvents=${result.observedEvents} activeClosures=${result.activeClosures}`, + `[stackflow-node] /new_block processed observedEvents=${result.observedEvents} activeClosures=${result.activeClosures}`, ); writeJson(response, 200, { ok: true, ...result }); } catch (error) { console.error( - `[watchtower] /new_block error: ${ + `[stackflow-node] /new_block error: ${ error instanceof Error ? error.message : 'failed to ingest payload' }`, ); @@ -837,7 +837,7 @@ function createHandler({ const burnBlockHeight = extractBurnBlockHeight(payload); if (!burnBlockHeight) { - console.warn('[watchtower] /new_burn_block ignored: missing burn block height'); + console.warn('[stackflow-node] /new_burn_block ignored: missing burn block height'); writeJson(response, 200, { ok: true, ignored: true, @@ -847,14 +847,14 @@ function createHandler({ return; } - const result = await watchtower.ingestBurnBlock(burnBlockHeight, url.pathname); + const result = await stackflowNode.ingestBurnBlock(burnBlockHeight, url.pathname); writeJson(response, 200, { ok: true, ...result, }); } catch (error) { console.error( - `[watchtower] /new_burn_block error: ${ + `[stackflow-node] /new_burn_block error: ${ error instanceof Error ? error.message : 'failed to process burn block' }`, ); @@ -893,7 +893,7 @@ function createHandler({ }; } -function start(): void { +async function start(): Promise { const config = loadConfig(); const stateStore = new SqliteStateStore({ dbFile: config.dbFile, @@ -911,7 +911,7 @@ function start(): void { return new MockDisputeExecutor(); } - return config.signerKey + return config.disputeSignerKey ? new StacksDisputeExecutor(config) : new NoopDisputeExecutor(); })(); @@ -928,30 +928,46 @@ function start(): void { return new ReadOnlySignatureVerifier(config); })(); - const watchtower = new Watchtower({ + const counterpartySigner = createCounterpartySigner(config); + await counterpartySigner.ensureReady(); + const effectiveWatchedPrincipals = (() => { + const counterpartyPrincipal = counterpartySigner.counterpartyPrincipal; + if (config.watchedPrincipals.length === 0) { + return counterpartyPrincipal ? [counterpartyPrincipal] : []; + } + + if (!counterpartyPrincipal) { + return config.watchedPrincipals; + } + + return Array.from( + new Set([...config.watchedPrincipals, counterpartyPrincipal]), + ); + })(); + + const stackflowNode = new StackflowNode({ stateStore, watchedContracts: config.watchedContracts, - watchedPrincipals: config.watchedPrincipals, + watchedPrincipals: effectiveWatchedPrincipals, disputeExecutor, disputeOnlyBeneficial: config.disputeOnlyBeneficial, signatureVerifier, }); - const producerSigner = createProducerSigner(config); - const producerService = new ProducerService({ - watchtower, - signer: producerSigner, + const counterpartyService = new CounterpartyService({ + stackflowNode, + signer: counterpartySigner, }); const startedAt = new Date().toISOString(); const server = http.createServer( createHandler({ - watchtower, - producerService, + stackflowNode, + counterpartyService, startedAt, disputeEnabled: disputeExecutor.enabled, signerAddress: disputeExecutor.signerAddress, - producerEnabled: producerService.enabled, - producerPrincipal: producerService.producerPrincipal, + counterpartyEnabled: counterpartyService.enabled, + counterpartyPrincipal: counterpartyService.counterpartyPrincipal, stacksNetwork: config.stacksNetwork, watchedContracts: config.watchedContracts, logRawEvents: config.logRawEvents, @@ -964,35 +980,47 @@ function start(): void { ? config.watchedContracts.join(', ') : '[auto: any *.stackflow* contract]'; const watchedPrincipals = - config.watchedPrincipals.length > 0 - ? config.watchedPrincipals.join(', ') + effectiveWatchedPrincipals.length > 0 + ? effectiveWatchedPrincipals.join(', ') : '[auto: any principal]'; console.log( - `[watchtower] listening on http://${config.host}:${config.port} ` + + `[stackflow-node] listening on http://${config.host}:${config.port} ` + `contracts=${watchedContracts} db=${config.dbFile} ` + `principals=${watchedPrincipals} disputes=${disputeExecutor.enabled ? 'enabled' : 'disabled'} ` + `dispute-mode=${config.disputeExecutorMode} verifier-mode=${config.signatureVerifierMode} ` + - `producer-signer-mode=${config.producerSignerMode} ` + - `producer-signing=${producerService.enabled ? 'enabled' : 'disabled'} producer-principal=${ - producerService.producerPrincipal ?? '-' + `counterparty-signer-mode=${config.counterpartySignerMode} ` + + `counterparty-signing=${counterpartyService.enabled ? 'enabled' : 'disabled'} counterparty-principal=${ + counterpartyService.counterpartyPrincipal ?? '-' }`, ); if (config.signatureVerifierMode !== 'readonly') { console.warn( - `[watchtower] non-readonly signature verifier mode active: ${config.signatureVerifierMode}`, + `[stackflow-node] non-readonly signature verifier mode active: ${config.signatureVerifierMode}`, ); } if (config.disputeExecutorMode !== 'auto') { console.warn( - `[watchtower] non-auto dispute executor mode active: ${config.disputeExecutorMode}`, + `[stackflow-node] non-auto dispute executor mode active: ${config.disputeExecutorMode}`, ); } if (config.logRawEvents) { - console.warn('[watchtower] raw stackflow event logging is enabled'); + console.warn('[stackflow-node] raw stackflow event logging is enabled'); + } + + if (counterpartySigner.counterpartyPrincipal) { + if (config.watchedPrincipals.length === 0) { + console.warn( + `[stackflow-node] STACKFLOW_NODE_PRINCIPALS is empty; restricting watchlist to counterparty principal ${counterpartySigner.counterpartyPrincipal}`, + ); + } else if (!config.watchedPrincipals.includes(counterpartySigner.counterpartyPrincipal)) { + console.warn( + `[stackflow-node] added counterparty principal to watchlist: ${counterpartySigner.counterpartyPrincipal}`, + ); + } } }); @@ -1003,15 +1031,15 @@ function start(): void { } shuttingDown = true; - console.log(`[watchtower] received ${signal}, shutting down`); + console.log(`[stackflow-node] received ${signal}, shutting down`); server.close(() => { stateStore.close(); - console.log('[watchtower] shutdown complete'); + console.log('[stackflow-node] shutdown complete'); process.exit(0); }); setTimeout(() => { - console.error('[watchtower] forced shutdown timeout reached'); + console.error('[stackflow-node] forced shutdown timeout reached'); stateStore.close(); process.exit(1); }, 10000).unref(); @@ -1021,4 +1049,11 @@ function start(): void { process.on('SIGTERM', () => shutdown('SIGTERM')); } -start(); +start().catch((error) => { + console.error( + `[stackflow-node] fatal startup error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exit(1); +}); diff --git a/server/src/producer-service.ts b/server/src/producer-service.ts deleted file mode 100644 index 7472993..0000000 --- a/server/src/producer-service.ts +++ /dev/null @@ -1,831 +0,0 @@ -import { createHash } from 'node:crypto'; - -import { createNetwork } from '@stacks/network'; -import { - ClarityType, - bufferCV, - fetchCallReadOnlyFunction, - getAddressFromPrivateKey, - noneCV, - principalCV, - signStructuredData, - someCV, - stringAsciiCV, - tupleCV, - uintCV, -} from '@stacks/transactions'; - -import { - canonicalPipeKey, - hexToBytes, - isValidHex, - normalizeHex, - parseOptionalUInt, - parsePrincipal, - parseUInt, - splitContractId, -} from './principal-utils.js'; -import { normalizePipeId } from './observer-parser.js'; -import { describeStackflowContractError } from './signature-verifier.js'; -import type { - ProducerSignerMode, - SignatureStateUpsertResult, - SignatureVerificationResult, - SignatureVerifierMode, - WatchtowerConfig, -} from './types.js'; -import { - PrincipalNotWatchedError, - SignatureValidationError, - Watchtower, -} from './watchtower.js'; - -const ACTION_CLOSE = '0'; -const ACTION_TRANSFER = '1'; -const ACTION_DEPOSIT = '2'; -const ACTION_WITHDRAWAL = '3'; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function normalizeContractId(input: unknown): string { - if (typeof input !== 'string' || input.trim() === '') { - throw new ProducerServiceError(400, 'contractId must be a non-empty string'); - } - - const contractId = input.trim(); - try { - splitContractId(contractId); - } catch { - throw new ProducerServiceError(400, 'invalid contractId'); - } - return contractId; -} - -function normalizeToken(input: unknown): string | null { - if (input === null || input === undefined || input === '') { - return null; - } - - try { - return parsePrincipal(input, 'token'); - } catch (error) { - throw new ProducerServiceError( - 400, - error instanceof Error ? error.message : 'token must be a principal', - ); - } -} - -function normalizeHexBuff(input: unknown, bytes: number, fieldName: string): string { - if (typeof input !== 'string' || input.trim() === '') { - throw new ProducerServiceError(400, `${fieldName} must be a hex string`); - } - - const value = input.trim().toLowerCase(); - if (!isValidHex(value, bytes)) { - throw new ProducerServiceError(400, `${fieldName} must be ${bytes} bytes of hex`); - } - - return value.startsWith('0x') ? value : `0x${value}`; -} - -function normalizeOptionalHexBuff( - input: unknown, - bytes: number, - fieldName: string, -): string | null { - if (input === null || input === undefined || input === '') { - return null; - } - - return normalizeHexBuff(input, bytes, fieldName); -} - -function normalizeBool(input: unknown, fallback: boolean): boolean { - if (input === undefined || input === null || input === '') { - return fallback; - } - - if (typeof input === 'boolean') { - return input; - } - - const normalized = String(input).trim().toLowerCase(); - if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) { - return true; - } - if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) { - return false; - } - - throw new ProducerServiceError(400, 'beneficialOnly must be a boolean'); -} - -function chainIdForNetwork(network: WatchtowerConfig['stacksNetwork']): bigint { - if (network === 'mainnet') { - return 1n; - } - return 2_147_483_648n; -} - -function senderAddressForPrincipal(principal: string): string { - if (principal.includes('.')) { - return splitContractId(principal).address; - } - return principal; -} - -function parsePrincipalField(value: unknown, fieldName: string): string { - try { - return parsePrincipal(value, fieldName); - } catch (error) { - throw new ProducerServiceError( - 400, - error instanceof Error - ? error.message - : `${fieldName} must be a principal string`, - ); - } -} - -function parseUIntField(value: unknown, fieldName: string): string { - try { - return parseUInt(value); - } catch { - throw new ProducerServiceError(400, `${fieldName} must be a uint`); - } -} - -function parseOptionalUIntField(value: unknown, fieldName: string): string | null { - try { - return parseOptionalUInt(value); - } catch { - throw new ProducerServiceError(400, `${fieldName} must be a uint`); - } -} - -type ProducerStateSource = 'onchain' | 'signature-state'; - -interface ProducerStateBaseline { - source: ProducerStateSource; - nonce: string; - nonceValue: bigint; - myBalance: string; - myBalanceValue: bigint; - theirBalance: string; - theirBalanceValue: bigint; - updatedAt: string; -} - -function parseUnsignedBigInt(value: string | null): bigint | null { - if (value === null || !/^\d+$/.test(value)) { - return null; - } - - try { - return BigInt(value); - } catch { - return null; - } -} - -function shouldReplaceBaseline( - existing: ProducerStateBaseline, - incoming: ProducerStateBaseline, -): boolean { - if (incoming.nonceValue !== existing.nonceValue) { - return incoming.nonceValue > existing.nonceValue; - } - - if (incoming.updatedAt !== existing.updatedAt) { - return incoming.updatedAt > existing.updatedAt; - } - - if (incoming.source !== existing.source) { - return incoming.source === 'onchain'; - } - - return false; -} - -export interface ProducerSignRequest { - contractId: string; - forPrincipal: string; - withPrincipal: string; - token: string | null; - amount: string; - myBalance: string; - theirBalance: string; - theirSignature: string; - nonce: string; - action: string; - actor: string; - secret: string | null; - validAfter: string | null; - beneficialOnly: boolean; -} - -interface ParseProducerSignRequestOptions { - producerPrincipal: string; - allowedActions: Set; - defaultAction: string | null; -} - -function parseProducerSignRequest( - input: unknown, - options: ParseProducerSignRequestOptions, -): ProducerSignRequest { - if (!isRecord(input)) { - throw new ProducerServiceError(400, 'payload must be an object'); - } - - const data = input; - const forPrincipalInput = data.forPrincipal; - if (forPrincipalInput !== undefined && forPrincipalInput !== null && forPrincipalInput !== '') { - const parsedForPrincipal = parsePrincipalField(forPrincipalInput, 'forPrincipal'); - if (parsedForPrincipal !== options.producerPrincipal) { - throw new ProducerServiceError( - 400, - `forPrincipal must match producer principal ${options.producerPrincipal}`, - ); - } - } - - const actionInput = - data.action !== undefined && data.action !== null && data.action !== '' - ? data.action - : options.defaultAction; - if (actionInput === null) { - throw new ProducerServiceError(400, 'action is required'); - } - - const action = parseUIntField(actionInput, 'action'); - if (!options.allowedActions.has(action)) { - throw new ProducerServiceError( - 400, - `action ${action} is not allowed for this endpoint`, - ); - } - - const amount = - action === ACTION_DEPOSIT || action === ACTION_WITHDRAWAL - ? parseUIntField(data.amount, 'amount') - : parseOptionalUIntField(data.amount, 'amount') || '0'; - - return { - contractId: normalizeContractId(data.contractId), - forPrincipal: options.producerPrincipal, - withPrincipal: parsePrincipalField(data.withPrincipal, 'withPrincipal'), - token: normalizeToken(data.token), - amount, - myBalance: parseUIntField(data.myBalance, 'myBalance'), - theirBalance: parseUIntField(data.theirBalance, 'theirBalance'), - theirSignature: normalizeHexBuff( - data.theirSignature ?? data.counterpartySignature, - 65, - 'theirSignature', - ), - nonce: parseUIntField(data.nonce, 'nonce'), - action, - actor: parsePrincipalField(data.actor, 'actor'), - secret: normalizeOptionalHexBuff(data.secret, 32, 'secret'), - validAfter: parseOptionalUIntField(data.validAfter, 'validAfter'), - beneficialOnly: normalizeBool(data.beneficialOnly, false), - }; -} - -export interface ProducerSigner { - readonly enabled: boolean; - readonly producerPrincipal: string | null; - readonly signerAddress: string | null; - verifyCounterpartySignature( - request: ProducerSignRequest, - ): Promise; - signMySignature(request: ProducerSignRequest): string; -} - -export class ProducerStateSigner implements ProducerSigner { - readonly enabled: boolean; - - readonly producerPrincipal: string | null; - - readonly signerAddress: string | null; - - private readonly producerKey: string | null; - - private readonly network: ReturnType; - - private readonly signatureVerifierMode: SignatureVerifierMode; - - private readonly stackflowMessageVersion: string; - - private readonly stacksNetwork: WatchtowerConfig['stacksNetwork']; - - constructor( - config: Pick< - WatchtowerConfig, - | 'stacksNetwork' - | 'stacksApiUrl' - | 'signatureVerifierMode' - | 'producerKey' - | 'producerPrincipal' - | 'stackflowMessageVersion' - >, - ) { - this.network = createNetwork({ - network: config.stacksNetwork, - client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, - }); - - this.signatureVerifierMode = config.signatureVerifierMode; - this.stackflowMessageVersion = config.stackflowMessageVersion; - this.stacksNetwork = config.stacksNetwork; - this.producerKey = config.producerKey - ? normalizeHex(config.producerKey).slice(2) - : null; - this.signerAddress = this.producerKey - ? getAddressFromPrivateKey(this.producerKey, this.network) - : null; - this.enabled = Boolean(this.producerKey); - - if (!this.enabled) { - this.producerPrincipal = null; - return; - } - - if (config.producerPrincipal?.trim()) { - const parsedProducerPrincipal = parsePrincipal( - config.producerPrincipal, - 'WATCHTOWER_PRODUCER_PRINCIPAL', - ); - if ( - !parsedProducerPrincipal.includes('.') && - parsedProducerPrincipal !== this.signerAddress - ) { - throw new Error( - `WATCHTOWER_PRODUCER_PRINCIPAL (${parsedProducerPrincipal}) does not match producer key address (${this.signerAddress})`, - ); - } - this.producerPrincipal = parsedProducerPrincipal; - return; - } - - this.producerPrincipal = this.signerAddress; - } - - async verifyCounterpartySignature( - request: ProducerSignRequest, - ): Promise { - if (!this.enabled || !this.producerPrincipal) { - return { valid: false, reason: 'producer signing is not configured' }; - } - - if (request.forPrincipal !== this.producerPrincipal) { - return { - valid: false, - reason: `forPrincipal must be ${this.producerPrincipal}`, - }; - } - - if (this.signatureVerifierMode === 'accept-all') { - return { valid: true, reason: null }; - } - if (this.signatureVerifierMode === 'reject-all') { - return { valid: false, reason: 'invalid-signature' }; - } - - const contract = splitContractId(request.contractId); - const pipeKey = canonicalPipeKey( - request.token, - request.forPrincipal, - request.withPrincipal, - ); - const balance1 = - pipeKey['principal-1'] === request.forPrincipal - ? request.myBalance - : request.theirBalance; - const balance2 = - pipeKey['principal-1'] === request.forPrincipal - ? request.theirBalance - : request.myBalance; - - const tokenArg = request.token ? someCV(principalCV(request.token)) : noneCV(); - const secretArg = request.secret - ? someCV(bufferCV(hexToBytes(request.secret))) - : noneCV(); - const validAfterArg = request.validAfter - ? someCV(uintCV(BigInt(request.validAfter))) - : noneCV(); - - const response = await fetchCallReadOnlyFunction({ - network: this.network, - senderAddress: senderAddressForPrincipal(this.producerPrincipal), - contractAddress: contract.address, - contractName: contract.name, - functionName: 'verify-signature-request', - functionArgs: [ - bufferCV(hexToBytes(request.theirSignature)), - principalCV(request.withPrincipal), - tupleCV({ - token: tokenArg, - 'principal-1': principalCV(pipeKey['principal-1']), - 'principal-2': principalCV(pipeKey['principal-2']), - }), - uintCV(BigInt(balance1)), - uintCV(BigInt(balance2)), - uintCV(BigInt(request.nonce)), - uintCV(BigInt(request.action)), - principalCV(request.actor), - secretArg, - validAfterArg, - uintCV(BigInt(request.amount)), - ], - }); - - if (response.type === ClarityType.ResponseErr) { - if (response.value.type === ClarityType.UInt) { - return { - valid: false, - reason: describeStackflowContractError(response.value.value), - }; - } - return { valid: false, reason: 'contract error' }; - } - - if (response.type !== ClarityType.ResponseOk) { - return { valid: false, reason: 'unexpected-readonly-response' }; - } - - return { valid: true, reason: null }; - } - - signMySignature(request: ProducerSignRequest): string { - if (!this.enabled || !this.producerKey || !this.producerPrincipal) { - throw new ProducerServiceError(503, 'producer signing is not configured'); - } - - const pipeKey = canonicalPipeKey( - request.token, - request.forPrincipal, - request.withPrincipal, - ); - const balance1 = - pipeKey['principal-1'] === request.forPrincipal - ? request.myBalance - : request.theirBalance; - const balance2 = - pipeKey['principal-1'] === request.forPrincipal - ? request.theirBalance - : request.myBalance; - - const hashedSecret = request.secret - ? someCV(bufferCV(createHash('sha256').update(hexToBytes(request.secret)).digest())) - : noneCV(); - const validAfter = request.validAfter - ? someCV(uintCV(BigInt(request.validAfter))) - : noneCV(); - const token = request.token ? someCV(principalCV(request.token)) : noneCV(); - - const message = tupleCV({ - token, - 'principal-1': principalCV(pipeKey['principal-1']), - 'principal-2': principalCV(pipeKey['principal-2']), - 'balance-1': uintCV(BigInt(balance1)), - 'balance-2': uintCV(BigInt(balance2)), - nonce: uintCV(BigInt(request.nonce)), - action: uintCV(BigInt(request.action)), - actor: principalCV(request.actor), - 'hashed-secret': hashedSecret, - 'valid-after': validAfter, - }); - - const domain = tupleCV({ - name: stringAsciiCV(request.contractId), - version: stringAsciiCV(this.stackflowMessageVersion), - 'chain-id': uintCV(chainIdForNetwork(this.stacksNetwork)), - }); - - const signature = signStructuredData({ - message, - domain, - privateKey: this.producerKey, - }); - return normalizeHex(signature); - } -} - -class UnsupportedProducerSigner implements ProducerSigner { - readonly enabled = false; - - readonly producerPrincipal = null; - - readonly signerAddress = null; - - private readonly reason: string; - - constructor(reason: string) { - this.reason = reason; - } - - async verifyCounterpartySignature( - _request: ProducerSignRequest, - ): Promise { - return { - valid: false, - reason: this.reason, - }; - } - - signMySignature(_request: ProducerSignRequest): string { - throw new ProducerServiceError(503, this.reason); - } -} - -export function createProducerSigner( - config: Pick< - WatchtowerConfig, - | 'stacksNetwork' - | 'stacksApiUrl' - | 'signatureVerifierMode' - | 'producerKey' - | 'producerPrincipal' - | 'producerSignerMode' - | 'stackflowMessageVersion' - >, -): ProducerSigner { - const mode = (config.producerSignerMode || 'local-key') as ProducerSignerMode; - - if (mode === 'kms') { - return new UnsupportedProducerSigner( - 'WATCHTOWER_PRODUCER_SIGNER_MODE=kms is not implemented yet', - ); - } - - return new ProducerStateSigner(config); -} - -export interface ProducerSignResult { - request: ProducerSignRequest; - mySignature: string; - upsert: SignatureStateUpsertResult; -} - -export class ProducerService { - private readonly watchtower: Watchtower; - - private readonly signer: ProducerSigner; - - constructor({ watchtower, signer }: { watchtower: Watchtower; signer: ProducerSigner }) { - this.watchtower = watchtower; - this.signer = signer; - } - - get enabled(): boolean { - return this.signer.enabled; - } - - get producerPrincipal(): string | null { - return this.signer.producerPrincipal; - } - - async signTransfer(payload: unknown): Promise { - return this.signState(payload, new Set([ACTION_TRANSFER]), ACTION_TRANSFER); - } - - async signSignatureRequest(payload: unknown): Promise { - return this.signState( - payload, - new Set([ACTION_CLOSE, ACTION_DEPOSIT, ACTION_WITHDRAWAL]), - null, - ); - } - - private resolveCurrentBaseline( - request: ProducerSignRequest, - ): ProducerStateBaseline | null { - const pipeKey = canonicalPipeKey( - request.token, - request.forPrincipal, - request.withPrincipal, - ); - const pipeId = normalizePipeId(pipeKey); - if (!pipeId) { - return null; - } - - const status = this.watchtower.status(); - let best: ProducerStateBaseline | null = null; - - const consider = (candidate: ProducerStateBaseline): void => { - if (!best || shouldReplaceBaseline(best, candidate)) { - best = candidate; - } - }; - - for (const observed of status.observedPipes) { - if (observed.contractId !== request.contractId || observed.pipeId !== pipeId) { - continue; - } - - const principal1IsProducer = observed.pipeKey['principal-1'] === request.forPrincipal; - const myBalance = principal1IsProducer ? observed.balance1 : observed.balance2; - const theirBalance = principal1IsProducer ? observed.balance2 : observed.balance1; - const nonceValue = parseUnsignedBigInt(observed.nonce); - const myBalanceValue = parseUnsignedBigInt(myBalance); - const theirBalanceValue = parseUnsignedBigInt(theirBalance); - if ( - nonceValue === null || - myBalanceValue === null || - theirBalanceValue === null - ) { - continue; - } - - consider({ - source: 'onchain', - nonce: observed.nonce as string, - nonceValue, - myBalance: myBalance as string, - myBalanceValue, - theirBalance: theirBalance as string, - theirBalanceValue, - updatedAt: observed.updatedAt, - }); - } - - for (const signature of status.signatureStates) { - if ( - signature.contractId !== request.contractId || - signature.pipeId !== pipeId || - signature.forPrincipal !== request.forPrincipal - ) { - continue; - } - - const nonceValue = parseUnsignedBigInt(signature.nonce); - const myBalanceValue = parseUnsignedBigInt(signature.myBalance); - const theirBalanceValue = parseUnsignedBigInt(signature.theirBalance); - if ( - nonceValue === null || - myBalanceValue === null || - theirBalanceValue === null - ) { - continue; - } - - consider({ - source: 'signature-state', - nonce: signature.nonce, - nonceValue, - myBalance: signature.myBalance, - myBalanceValue, - theirBalance: signature.theirBalance, - theirBalanceValue, - updatedAt: signature.updatedAt, - }); - } - - return best; - } - - private enforceSigningPolicy(request: ProducerSignRequest): void { - const baseline = this.resolveCurrentBaseline(request); - if (!baseline) { - throw new ProducerServiceError(409, 'unknown-pipe-state', { - reason: 'unknown-pipe-state', - }); - } - - const incomingNonce = BigInt(request.nonce); - if (incomingNonce <= baseline.nonceValue) { - throw new ProducerServiceError(409, 'nonce-too-low', { - reason: 'nonce-too-low', - incomingNonce: request.nonce, - existingNonce: baseline.nonce, - state: { - source: baseline.source, - nonce: baseline.nonce, - myBalance: baseline.myBalance, - theirBalance: baseline.theirBalance, - updatedAt: baseline.updatedAt, - }, - }); - } - - const requestedMyBalance = BigInt(request.myBalance); - const requestedTheirBalance = BigInt(request.theirBalance); - - if (requestedMyBalance < baseline.myBalanceValue) { - throw new ProducerServiceError(403, 'producer-balance-decrease-not-allowed', { - reason: 'producer-balance-decrease', - currentMyBalance: baseline.myBalance, - requestedMyBalance: request.myBalance, - }); - } - - if (request.action === ACTION_TRANSFER) { - const currentTotal = baseline.myBalanceValue + baseline.theirBalanceValue; - const requestedTotal = requestedMyBalance + requestedTheirBalance; - if (requestedTotal !== currentTotal) { - throw new ProducerServiceError(403, 'invalid-transfer-total', { - reason: 'invalid-transfer-total', - currentTotal: currentTotal.toString(10), - requestedTotal: requestedTotal.toString(10), - }); - } - - if (requestedMyBalance <= baseline.myBalanceValue) { - throw new ProducerServiceError(403, 'transfer-not-beneficial-for-producer', { - reason: 'transfer-not-beneficial', - currentMyBalance: baseline.myBalance, - requestedMyBalance: request.myBalance, - }); - } - } - } - - private async signState( - payload: unknown, - allowedActions: Set, - defaultAction: string | null, - ): Promise { - if (!this.signer.producerPrincipal) { - throw new ProducerServiceError(503, 'producer signing is not configured'); - } - - const request = parseProducerSignRequest(payload, { - producerPrincipal: this.signer.producerPrincipal, - allowedActions, - defaultAction, - }); - - this.enforceSigningPolicy(request); - - const verification = await this.signer.verifyCounterpartySignature(request); - if (!verification.valid) { - throw new ProducerServiceError( - 401, - verification.reason || 'counterparty signature invalid', - ); - } - - const mySignature = this.signer.signMySignature(request); - - try { - const upsert = await this.watchtower.upsertSignatureState({ - contractId: request.contractId, - forPrincipal: request.forPrincipal, - withPrincipal: request.withPrincipal, - token: request.token, - amount: request.amount, - myBalance: request.myBalance, - theirBalance: request.theirBalance, - mySignature, - theirSignature: request.theirSignature, - nonce: request.nonce, - action: request.action, - actor: request.actor, - secret: request.secret, - validAfter: request.validAfter, - beneficialOnly: request.beneficialOnly, - }, { - skipVerification: true, - }); - - return { - request, - mySignature, - upsert, - }; - } catch (error) { - if (error instanceof SignatureValidationError) { - throw new ProducerServiceError(401, error.message); - } - - if (error instanceof PrincipalNotWatchedError) { - throw new ProducerServiceError(403, error.message); - } - - throw error; - } - } -} - -export class ProducerServiceError extends Error { - readonly statusCode: number; - - readonly details: Record | null; - - constructor( - statusCode: number, - message: string, - details: Record | null = null, - ) { - super(message); - this.name = 'ProducerServiceError'; - this.statusCode = statusCode; - this.details = details; - } -} diff --git a/server/src/signature-verifier.ts b/server/src/signature-verifier.ts index 47477a0..90defb0 100644 --- a/server/src/signature-verifier.ts +++ b/server/src/signature-verifier.ts @@ -15,7 +15,7 @@ import type { SignatureStateInput, SignatureVerificationResult, SignatureVerifier, - WatchtowerConfig, + StackflowNodeConfig, } from './types.js'; const STACKFLOW_CONTRACT_ERROR_MESSAGES: Record = { @@ -69,7 +69,7 @@ function senderAddressForPrincipal(principal: string): string { export class ReadOnlySignatureVerifier implements SignatureVerifier { private readonly network: ReturnType; - constructor(config: Pick) { + constructor(config: Pick) { this.network = createNetwork({ network: config.stacksNetwork, client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, diff --git a/server/src/watchtower.ts b/server/src/stackflow-node.ts similarity index 86% rename from server/src/watchtower.ts rename to server/src/stackflow-node.ts index 07c459b..e2459e9 100644 --- a/server/src/watchtower.ts +++ b/server/src/stackflow-node.ts @@ -18,16 +18,16 @@ import type { IngestResult, ObservedPipeRecord, PipeKey, - RecordedWatchtowerEvent, + RecordedStackflowNodeEvent, SignatureStateInput, SignatureStateRecord, SignatureStateUpsertResult, SignatureVerifier, StackflowPrintEvent, - WatchtowerStatus, + StackflowNodeStatus, } from './types.js'; -interface WatchtowerOptions { +interface StackflowNodeOptions { stateStore: SqliteStateStore; watchedContracts?: string[]; watchedPrincipals?: string[]; @@ -234,7 +234,7 @@ function parseUnsignedBigInt(value: string | null): bigint | null { } } -export class Watchtower { +export class StackflowNode { private readonly stateStore: SqliteStateStore; private readonly watchedContracts: string[]; @@ -254,7 +254,7 @@ export class Watchtower { disputeExecutor, disputeOnlyBeneficial = false, signatureVerifier, - }: WatchtowerOptions) { + }: StackflowNodeOptions) { this.stateStore = stateStore; this.watchedContracts = watchedContracts; this.watchedPrincipals = new Set(watchedPrincipals); @@ -272,7 +272,7 @@ export class Watchtower { if (!this.isWatchedPrincipal(normalized.forPrincipal)) { console.warn( - `[watchtower] signature-state processed result=rejected reason=principal-not-watched ${context}`, + `[stackflow-node] signature-state processed result=rejected reason=principal-not-watched ${context}`, ); throw new PrincipalNotWatchedError(normalized.forPrincipal); } @@ -284,7 +284,7 @@ export class Watchtower { if (!verification.valid) { console.warn( - `[watchtower] signature-state processed result=rejected reason=${ + `[stackflow-node] signature-state processed result=rejected reason=${ verification.reason || 'invalid-signature' } ${context}`, ); @@ -324,7 +324,7 @@ export class Watchtower { if (incomingNonce <= existingNonce) { console.log( - `[watchtower] signature-state processed result=ignored reason=nonce-not-higher incomingNonce=${incomingNonce.toString( + `[stackflow-node] signature-state processed result=ignored reason=nonce-not-higher incomingNonce=${incomingNonce.toString( 10, )} existingNonce=${existingNonce.toString(10)} ${pipeContext}`, ); @@ -338,7 +338,7 @@ export class Watchtower { this.stateStore.setSignatureState(nextState); console.log( - `[watchtower] signature-state processed result=stored replaced=true ${pipeContext}`, + `[stackflow-node] signature-state processed result=stored replaced=true ${pipeContext}`, ); return { stored: true, @@ -350,7 +350,7 @@ export class Watchtower { this.stateStore.setSignatureState(nextState); console.log( - `[watchtower] signature-state processed result=stored replaced=false ${pipeContext}`, + `[stackflow-node] signature-state processed result=stored replaced=false ${pipeContext}`, ); return { stored: true, @@ -366,7 +366,7 @@ export class Watchtower { }); console.log( - `[watchtower] stackflow events extracted=${events.length} source=${source ?? 'unknown'}`, + `[stackflow-node] stackflow events extracted=${events.length} source=${source ?? 'unknown'}`, ); let observedEvents = 0; @@ -374,7 +374,7 @@ export class Watchtower { const pipeId = event.pipeKey ? normalizePipeId(event.pipeKey) : null; const watchedPipe = this.isWatchedPipe(event.pipeKey); console.log( - `[watchtower] stackflow event detected contract=${event.contractId} event=${ + `[stackflow-node] stackflow event detected contract=${event.contractId} event=${ event.eventName ?? 'unknown' } txid=${event.txid ?? '-'} pipeId=${pipeId ?? '-'} watchedPipe=${watchedPipe}`, ); @@ -487,14 +487,14 @@ export class Watchtower { this.stateStore.setObservedPipe(nextPipe); console.log( - `[watchtower] pending settled pipeId=${observedPipe.pipeId} burnBlock=${burnBlockHeight.toString( + `[stackflow-node] pending settled pipeId=${observedPipe.pipeId} burnBlock=${burnBlockHeight.toString( 10, )} balance1=${nextPipe.balance1 ?? '-'} balance2=${nextPipe.balance2 ?? '-'}`, ); } console.log( - `[watchtower] burn block processed height=${burnBlockHeight.toString( + `[stackflow-node] burn block processed height=${burnBlockHeight.toString( 10, )} source=${source ?? 'unknown'} settledPipes=${settledPipes}`, ); @@ -533,7 +533,7 @@ export class Watchtower { event: StackflowPrintEvent, source: string | null = null, ): Promise { - const processedEvent: RecordedWatchtowerEvent = { + const processedEvent: RecordedStackflowNodeEvent = { ...event, source, observedAt: new Date().toISOString(), @@ -541,19 +541,19 @@ export class Watchtower { this.stateStore.recordEvent(processedEvent); console.log( - `[watchtower] event recorded event=${event.eventName ?? 'unknown'} txid=${event.txid ?? '-'} source=${ + `[stackflow-node] event recorded event=${event.eventName ?? 'unknown'} txid=${event.txid ?? '-'} source=${ source ?? 'unknown' }`, ); if (!event.pipeKey || !event.eventName) { - console.log('[watchtower] event skipped reason=missing-pipe-or-event-name'); + console.log('[stackflow-node] event skipped reason=missing-pipe-or-event-name'); return; } const pipeId = normalizePipeId(event.pipeKey); if (!pipeId) { - console.log('[watchtower] event skipped reason=invalid-pipe-id'); + console.log('[stackflow-node] event skipped reason=invalid-pipe-id'); return; } @@ -581,7 +581,7 @@ export class Watchtower { }; this.stateStore.setObservedPipe(observedPipe); console.log( - `[watchtower] observed pipe updated pipeId=${pipeId} event=${event.eventName} nonce=${ + `[stackflow-node] observed pipe updated pipeId=${pipeId} event=${event.eventName} nonce=${ observedPipe.nonce ?? '-' }`, ); @@ -605,7 +605,7 @@ export class Watchtower { this.stateStore.setClosure(closure); console.log( - `[watchtower] closure opened pipeId=${pipeId} event=${event.eventName} nonce=${ + `[stackflow-node] closure opened pipeId=${pipeId} event=${event.eventName} nonce=${ closure.nonce ?? '-' } expiresAt=${closure.expiresAt ?? '-'}`, ); @@ -636,7 +636,7 @@ export class Watchtower { this.stateStore.setObservedPipe(observedPipe); this.stateStore.deleteClosure(pipeId); console.log( - `[watchtower] terminal event settled pipeId=${pipeId} event=${event.eventName} balances-reset-to-zero`, + `[stackflow-node] terminal event settled pipeId=${pipeId} event=${event.eventName} balances-reset-to-zero`, ); return; } @@ -648,7 +648,7 @@ export class Watchtower { ): Promise { if (!this.disputeExecutor?.enabled) { console.log( - `[watchtower] dispute skipped reason=executor-disabled pipeId=${closure.pipeId} contract=${closure.contractId}`, + `[stackflow-node] dispute skipped reason=executor-disabled pipeId=${closure.pipeId} contract=${closure.contractId}`, ); return; } @@ -656,7 +656,7 @@ export class Watchtower { const closureNonce = toBigInt(closure.nonce); if (closureNonce === null) { console.log( - `[watchtower] dispute skipped reason=missing-closure-nonce pipeId=${closure.pipeId} contract=${closure.contractId}`, + `[stackflow-node] dispute skipped reason=missing-closure-nonce pipeId=${closure.pipeId} contract=${closure.contractId}`, ); return; } @@ -664,7 +664,7 @@ export class Watchtower { const closer = closure.closer; if (!closer) { console.log( - `[watchtower] dispute skipped reason=missing-closer pipeId=${closure.pipeId} contract=${closure.contractId}`, + `[stackflow-node] dispute skipped reason=missing-closer pipeId=${closure.pipeId} contract=${closure.contractId}`, ); return; } @@ -675,13 +675,13 @@ export class Watchtower { if (candidates.length === 0) { console.log( - `[watchtower] dispute skipped reason=no-counterparty-state pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer}`, + `[stackflow-node] dispute skipped reason=no-counterparty-state pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer}`, ); return; } console.log( - `[watchtower] dispute evaluate pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer} closureNonce=${closureNonce.toString( + `[stackflow-node] dispute evaluate pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer} closureNonce=${closureNonce.toString( 10, )} candidateStates=${candidates.length}`, ); @@ -712,7 +712,7 @@ export class Watchtower { if (!eligible) { console.log( - `[watchtower] dispute skipped reason=no-eligible-state pipeId=${closure.pipeId} contract=${closure.contractId}`, + `[stackflow-node] dispute skipped reason=no-eligible-state pipeId=${closure.pipeId} contract=${closure.contractId}`, ); return; } @@ -722,13 +722,13 @@ export class Watchtower { const existingAttempt = this.stateStore.getDisputeAttempt(attemptId); if (existingAttempt?.success) { console.log( - `[watchtower] dispute skipped reason=already-submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} attemptId=${attemptId}`, + `[stackflow-node] dispute skipped reason=already-submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} attemptId=${attemptId}`, ); return; } console.log( - `[watchtower] dispute submit pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} nonce=${eligible.nonce} triggerTxid=${triggerEvent.txid ?? '-'} mode=${ + `[stackflow-node] dispute submit pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} nonce=${eligible.nonce} triggerTxid=${triggerEvent.txid ?? '-'} mode=${ this.disputeExecutor.constructor.name }`, ); @@ -753,7 +753,7 @@ export class Watchtower { }; this.stateStore.setDisputeAttempt(attempt); console.log( - `[watchtower] dispute submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} disputeTxid=${result.txid}`, + `[stackflow-node] dispute submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} disputeTxid=${result.txid}`, ); } catch (error) { const attempt: DisputeAttemptRecord = { @@ -769,12 +769,12 @@ export class Watchtower { }; this.stateStore.setDisputeAttempt(attempt); console.error( - `[watchtower] dispute failed pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} error=${attempt.error}`, + `[stackflow-node] dispute failed pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} error=${attempt.error}`, ); } } - status(): WatchtowerStatus { + status(): StackflowNodeStatus { const snapshot = this.stateStore.getSnapshot(); return { diff --git a/server/src/state-store.ts b/server/src/state-store.ts index 88da899..88b6d25 100644 --- a/server/src/state-store.ts +++ b/server/src/state-store.ts @@ -7,9 +7,9 @@ import type { DisputeAttemptRecord, ObservedPipeRecord, PipeKey, - RecordedWatchtowerEvent, + RecordedStackflowNodeEvent, SignatureStateRecord, - WatchtowerPersistedState, + StackflowNodePersistedState, } from './types.js'; interface SqliteStateStoreOptions { @@ -87,7 +87,7 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } -function isLegacyState(value: unknown): value is WatchtowerPersistedState { +function isLegacyState(value: unknown): value is StackflowNodePersistedState { if (!isRecord(value)) { return false; } @@ -152,7 +152,7 @@ export class SqliteStateStore { const backupFile = `${this.dbFile}.json-backup-${Date.now()}`; fs.renameSync(this.dbFile, backupFile); console.log( - `[watchtower] migrated legacy JSON state to SQLite; backup=${backupFile}`, + `[stackflow-node] migrated legacy JSON state to SQLite; backup=${backupFile}`, ); } @@ -314,7 +314,7 @@ export class SqliteStateStore { } } - private loadLegacyJsonState(): WatchtowerPersistedState | null { + private loadLegacyJsonState(): StackflowNodePersistedState | null { if (!fs.existsSync(this.dbFile)) { return null; } @@ -337,7 +337,7 @@ export class SqliteStateStore { } } - private importLegacyState(legacyState: WatchtowerPersistedState): void { + private importLegacyState(legacyState: StackflowNodePersistedState): void { const db = this.getDb(); db.exec('BEGIN'); try { @@ -621,7 +621,7 @@ export class SqliteStateStore { }; } - getSnapshot(): WatchtowerPersistedState { + getSnapshot(): StackflowNodePersistedState { const db = this.getDb(); const closureRows = db @@ -668,10 +668,10 @@ export class SqliteStateStore { disputeAttempts[mapped.attemptId] = mapped; } - const recentEvents: RecordedWatchtowerEvent[] = []; + const recentEvents: RecordedStackflowNodeEvent[] = []; for (const row of eventRows) { try { - const parsed = JSON.parse(row.event_json) as RecordedWatchtowerEvent; + const parsed = JSON.parse(row.event_json) as RecordedStackflowNodeEvent; recentEvents.push(parsed); } catch { // Skip corrupted rows to keep the store usable. @@ -689,7 +689,7 @@ export class SqliteStateStore { }; } - recordEvent(event: RecordedWatchtowerEvent): void { + recordEvent(event: RecordedStackflowNodeEvent): void { const db = this.getDb(); const insert = db.prepare( 'INSERT INTO recent_events (event_json, observed_at) VALUES (?, ?)', diff --git a/server/src/types.ts b/server/src/types.ts index 1b0aee2..743eb10 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -135,29 +135,29 @@ export interface DisputeAttemptRecord { createdAt: string; } -export interface WatchtowerPersistedState { +export interface StackflowNodePersistedState { version: number; updatedAt: string | null; activeClosures: Record; observedPipes: Record; signatureStates: Record; disputeAttempts: Record; - recentEvents: RecordedWatchtowerEvent[]; + recentEvents: RecordedStackflowNodeEvent[]; } -export interface RecordedWatchtowerEvent extends StackflowPrintEvent { +export interface RecordedStackflowNodeEvent extends StackflowPrintEvent { source: string | null; observedAt: string; } -export interface WatchtowerStatus { +export interface StackflowNodeStatus { version: number; updatedAt: string | null; activeClosures: ClosureRecord[]; observedPipes: ObservedPipeRecord[]; signatureStates: SignatureStateRecord[]; disputeAttempts: DisputeAttemptRecord[]; - recentEvents: RecordedWatchtowerEvent[]; + recentEvents: RecordedStackflowNodeEvent[]; } export interface IngestResult { @@ -175,11 +175,11 @@ export type DisputeExecutorMode = | 'noop' | 'mock'; -export type ProducerSignerMode = +export type CounterpartySignerMode = | 'local-key' | 'kms'; -export interface WatchtowerConfig { +export interface StackflowNodeConfig { host: string; port: number; dbFile: string; @@ -189,10 +189,13 @@ export interface WatchtowerConfig { watchedPrincipals: string[]; stacksNetwork: 'mainnet' | 'testnet' | 'devnet' | 'mocknet'; stacksApiUrl: string | null; - signerKey: string | null; - producerKey: string | null; - producerPrincipal: string | null; - producerSignerMode: ProducerSignerMode; + disputeSignerKey: string | null; + counterpartyKey: string | null; + counterpartyPrincipal: string | null; + counterpartySignerMode: CounterpartySignerMode; + counterpartyKmsKeyId: string | null; + counterpartyKmsRegion: string | null; + counterpartyKmsEndpoint: string | null; stackflowMessageVersion: string; signatureVerifierMode: SignatureVerifierMode; disputeExecutorMode: DisputeExecutorMode; diff --git a/server/ui/index.html b/server/ui/index.html index 65bf1a2..196c956 100644 --- a/server/ui/index.html +++ b/server/ui/index.html @@ -23,8 +23,8 @@

Stackflow Console

Connection

diff --git a/server/ui/main.js b/server/ui/main.js index cbbe7b7..826efe5 100644 --- a/server/ui/main.js +++ b/server/ui/main.js @@ -14,10 +14,10 @@ var CHAIN_IDS = { }; var STORAGE_KEY = "stackflow-console-config-v1"; var connectedAddress = null; -var watchtowerProducerEnabled = false; -var watchtowerProducerPrincipal = null; +var stackflowNodeCounterpartyEnabled = false; +var stackflowNodeCounterpartyPrincipal = null; var ids = { - watchtowerUrl: "watchtower-url", + serverUrl: "stackflow-node-url", contractId: "contract-id", network: "network", contractVersion: "contract-version", @@ -204,9 +204,9 @@ var ACTION_DEFS = { "field-sig-my-signature" ] }, - "request-producer-transfer": { - submitLabel: "Request producer transfer signature", - help: "Send your transfer signature to the producer and receive their signature.", + "request-counterparty-transfer": { + submitLabel: "Request counterparty transfer signature", + help: "Send your transfer signature to the counterparty and receive their signature.", fields: [ "field-sig-with", "field-sig-token", @@ -220,9 +220,9 @@ var ACTION_DEFS = { "field-sig-their-signature" ] }, - "request-producer-deposit": { - submitLabel: "Request producer deposit signature", - help: "Send your deposit signature to the producer and receive their signature.", + "request-counterparty-deposit": { + submitLabel: "Request counterparty deposit signature", + help: "Send your deposit signature to the counterparty and receive their signature.", amountLabel: "deposit Amount", fields: [ "field-sig-with", @@ -238,9 +238,9 @@ var ACTION_DEFS = { "field-sig-their-signature" ] }, - "request-producer-withdrawal": { - submitLabel: "Request producer withdrawal signature", - help: "Send your withdrawal signature to the producer and receive their signature.", + "request-counterparty-withdrawal": { + submitLabel: "Request counterparty withdrawal signature", + help: "Send your withdrawal signature to the counterparty and receive their signature.", amountLabel: "withdraw Amount", fields: [ "field-sig-with", @@ -256,9 +256,9 @@ var ACTION_DEFS = { "field-sig-their-signature" ] }, - "request-producer-close": { - submitLabel: "Request producer close signature", - help: "Send your close signature to the producer and receive their signature.", + "request-counterparty-close": { + submitLabel: "Request counterparty close signature", + help: "Send your close signature to the counterparty and receive their signature.", fields: [ "field-sig-with", "field-sig-token", @@ -272,7 +272,7 @@ var ACTION_DEFS = { }, "submit-signature-state": { submitLabel: "Submit signature state", - help: "Send the latest signed state to the watchtower.", + help: "Send the latest signed state to the server.", fields: [ "field-sig-with", "field-sig-token", @@ -289,20 +289,20 @@ var ACTION_DEFS = { } }; var PRODUCER_ACTION_CONFIG = { - "request-producer-transfer": { - endpoint: "/producer/transfer", + "request-counterparty-transfer": { + endpoint: "/counterparty/transfer", action: "1" }, - "request-producer-close": { - endpoint: "/producer/signature-request", + "request-counterparty-close": { + endpoint: "/counterparty/signature-request", action: "0" }, - "request-producer-deposit": { - endpoint: "/producer/signature-request", + "request-counterparty-deposit": { + endpoint: "/counterparty/signature-request", action: "2" }, - "request-producer-withdrawal": { - endpoint: "/producer/signature-request", + "request-counterparty-withdrawal": { + endpoint: "/counterparty/signature-request", action: "3" } }; @@ -334,21 +334,21 @@ function setSignedActionForSelection(action) { "sign-transfer": "1", "sign-deposit": "2", "sign-withdrawal": "3", - "request-producer-close": "0", - "request-producer-transfer": "1", - "request-producer-deposit": "2", - "request-producer-withdrawal": "3" + "request-counterparty-close": "0", + "request-counterparty-transfer": "1", + "request-counterparty-deposit": "2", + "request-counterparty-withdrawal": "3" }; const value = mapping[action]; if (value !== void 0) { getInput(ids.sigAction).value = value; } } -function getProducerActionConfig(action) { +function getCounterpartyActionConfig(action) { return PRODUCER_ACTION_CONFIG[action] || null; } -function isProducerRequestAction(action) { - return Boolean(getProducerActionConfig(action)); +function isCounterpartyRequestAction(action) { + return Boolean(getCounterpartyActionConfig(action)); } function updateActionUi() { const action = getSelectedAction(); @@ -381,18 +381,18 @@ function updateActionUi() { if (mySigHelp) { mySigHelp.textContent = signAction ? "Click the submit button to generate this signature. It will auto-fill here." : "Paste your signature, or switch to a sign-* action to generate it here."; } - if (isProducerRequestAction(action) && !normalizedText(getInput(ids.sigWith).value) && watchtowerProducerPrincipal) { - getInput(ids.sigWith).value = watchtowerProducerPrincipal; + if (isCounterpartyRequestAction(action) && !normalizedText(getInput(ids.sigWith).value) && stackflowNodeCounterpartyPrincipal) { + getInput(ids.sigWith).value = stackflowNodeCounterpartyPrincipal; } - let producerHint = ""; - if (isProducerRequestAction(action)) { - if (watchtowerProducerEnabled && watchtowerProducerPrincipal) { - producerHint = ` Producer principal: ${watchtowerProducerPrincipal}.`; + let counterpartyHint = ""; + if (isCounterpartyRequestAction(action)) { + if (stackflowNodeCounterpartyEnabled && stackflowNodeCounterpartyPrincipal) { + counterpartyHint = ` Counterparty principal: ${stackflowNodeCounterpartyPrincipal}.`; } else { - producerHint = " Producer signing is not reported as enabled by the watchtower."; + counterpartyHint = " Counterparty signing is not reported as enabled by the server."; } } - setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${producerHint}`, false); + setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${counterpartyHint}`, false); setSignedActionForSelection(action); } function normalizedText(value) { @@ -452,7 +452,7 @@ function makePostConditionForTransfer(principal, tokenContractId, amount) { } function saveConfig() { const data = { - watchtowerUrl: getInput(ids.watchtowerUrl).value.trim(), + serverUrl: getInput(ids.serverUrl).value.trim(), contractId: getInput(ids.contractId).value.trim(), network: getInput(ids.network).value.trim(), contractVersion: getInput(ids.contractVersion).value.trim() @@ -466,8 +466,8 @@ function loadConfig() { } try { const parsed = JSON.parse(raw); - if (typeof parsed.watchtowerUrl === "string") { - getInput(ids.watchtowerUrl).value = parsed.watchtowerUrl; + if (typeof parsed.serverUrl === "string") { + getInput(ids.serverUrl).value = parsed.serverUrl; } if (typeof parsed.contractId === "string") { getInput(ids.contractId).value = parsed.contractId; @@ -482,7 +482,7 @@ function loadConfig() { } } function defaultConfig() { - getInput(ids.watchtowerUrl).value = window.location.origin; + getInput(ids.serverUrl).value = window.location.origin; getInput(ids.contractVersion).value = "0.6.0"; } function toBigInt(value, field) { @@ -808,7 +808,7 @@ function extractTxid(response) { } return null; } -function buildWatchtowerPayload() { +function buildStackflowNodePayload() { const parsed = parseSignerInputs(); const contractId = parseContractId(); const mySignature = normalizeHex( @@ -840,10 +840,10 @@ function buildWatchtowerPayload() { beneficialOnly: false }; } -function buildProducerRequestPayload(action) { - const config = getProducerActionConfig(action); +function buildCounterpartyRequestPayload(action) { + const config = getCounterpartyActionConfig(action); if (!config) { - throw new Error(`Unsupported producer action: ${action}`); + throw new Error(`Unsupported counterparty action: ${action}`); } if (!connectedAddress) { throw new Error("Connect wallet first"); @@ -891,21 +891,21 @@ function renderPayloadPreview() { } return; } - if (isProducerRequestAction(action)) { + if (isCounterpartyRequestAction(action)) { try { - const request2 = buildProducerRequestPayload(action); + const request2 = buildCounterpartyRequestPayload(action); $(ids.signaturePayload).textContent = JSON.stringify(request2, null, 2); } catch (error) { - $(ids.signaturePayload).textContent = error instanceof Error ? error.message : "invalid producer request"; + $(ids.signaturePayload).textContent = error instanceof Error ? error.message : "invalid counterparty request"; } return; } if (action !== "submit-signature-state") { - $(ids.signaturePayload).textContent = "Payload preview appears for submit-signature-state and producer requests."; + $(ids.signaturePayload).textContent = "Payload preview appears for submit-signature-state and counterparty requests."; return; } try { - const payload = buildWatchtowerPayload(); + const payload = buildStackflowNodePayload(); $(ids.signaturePayload).textContent = JSON.stringify(payload, null, 2); } catch (error) { $(ids.signaturePayload).textContent = error instanceof Error ? error.message : "invalid signature payload"; @@ -994,9 +994,9 @@ function pipeMatchesParticipants(pipe, connected, withPrincipal, token) { return pipeToken === token && (principal1 === connected && principal2 === withPrincipal || principal2 === connected && principal1 === withPrincipal); } async function resolvePipeTotals(withPrincipal, token) { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } const body = await fetchJson( `${baseUrl}/pipes?limit=500&principal=${encodeURIComponent(connectedAddress)}` @@ -1023,9 +1023,9 @@ async function refreshPipes() { return; } try { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } const [pipeBody, closureBody] = await Promise.all([ fetchJson( @@ -1174,10 +1174,10 @@ async function signStructuredState() { } async function submitSignatureState() { try { - const payload = buildWatchtowerPayload(); - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + const payload = buildStackflowNodePayload(); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } const response = await fetch(`${baseUrl}/signature-states`, { method: "POST", @@ -1213,12 +1213,12 @@ async function submitSignatureState() { ); } } -async function requestProducerSignature(action) { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); +async function requestCounterpartySignature(action) { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } - const requestPayload = buildProducerRequestPayload(action); + const requestPayload = buildCounterpartyRequestPayload(action); const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { method: "POST", headers: { "content-type": "application/json" }, @@ -1230,7 +1230,7 @@ async function requestProducerSignature(action) { const existingNonce = body?.existingNonce ?? body?.state?.nonce ?? "?"; setStatus( ids.txResult, - `Producer request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + `Counterparty request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, true ); return; @@ -1239,22 +1239,22 @@ async function requestProducerSignature(action) { const message = typeof body?.error === "string" ? body.error : `${response.status} ${response.statusText}`; throw new Error(message); } - const producerSignature = normalizeHex( + const counterpartySignature = normalizeHex( body?.mySignature, - "Producer signature", + "Counterparty signature", 65 ); - getInput(ids.sigTheirSignature).value = producerSignature; + getInput(ids.sigTheirSignature).value = counterpartySignature; renderPayloadPreview(); setStatus( ids.txResult, - `Producer signature received (stored=${body.stored}, replaced=${body.replaced}).` + `Counterparty signature received (stored=${body.stored}, replaced=${body.replaced}).` ); await refreshPipes(); } function bindInputs() { const configIds = [ - ids.watchtowerUrl, + ids.serverUrl, ids.contractId, ids.network, ids.contractVersion @@ -1262,8 +1262,8 @@ function bindInputs() { for (const id of configIds) { getInput(id).addEventListener("change", saveConfig); } - getInput(ids.watchtowerUrl).addEventListener("change", async () => { - await syncNetworkFromWatchtower(); + getInput(ids.serverUrl).addEventListener("change", async () => { + await syncNetworkFromStackflowNode(); }); getInput(ids.actionSelect).addEventListener("change", () => { updateActionUi(); @@ -1309,15 +1309,15 @@ function normalizeNetworkName(value) { } return null; } -async function syncNetworkFromWatchtower() { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); +async function syncNetworkFromStackflowNode() { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { return; } try { const health = await fetchJson(`${baseUrl}/health`); - watchtowerProducerEnabled = Boolean(health?.producerEnabled); - watchtowerProducerPrincipal = typeof health?.producerPrincipal === "string" && normalizedText(health.producerPrincipal) ? health.producerPrincipal : null; + stackflowNodeCounterpartyEnabled = Boolean(health?.counterpartyEnabled); + stackflowNodeCounterpartyPrincipal = typeof health?.counterpartyPrincipal === "string" && normalizedText(health.counterpartyPrincipal) ? health.counterpartyPrincipal : null; const remoteNetwork = normalizeNetworkName(health?.stacksNetwork); if (!remoteNetwork) { return; @@ -1328,10 +1328,10 @@ async function syncNetworkFromWatchtower() { saveConfig(); setStatus( ids.walletStatus, - `Network auto-synced from watchtower: ${remoteNetwork}` + `Network auto-synced from server: ${remoteNetwork}` ); } - if (isProducerRequestAction(getSelectedAction())) { + if (isCounterpartyRequestAction(getSelectedAction())) { updateActionUi(); renderPayloadPreview(); } @@ -1565,8 +1565,8 @@ async function executeSelectedAction() { await submitSignatureState(); return; } - if (isProducerRequestAction(action)) { - await requestProducerSignature(action); + if (isCounterpartyRequestAction(action)) { + await requestCounterpartySignature(action); return; } throw new Error(`Unsupported action: ${action}`); @@ -1593,7 +1593,7 @@ async function init() { bindInputs(); wireActions(); updateActionUi(); - await syncNetworkFromWatchtower(); + await syncNetworkFromStackflowNode(); await initWalletState(); if (!connectedAddress) { renderPipesPlaceholder("Connect wallet to load watched pipes."); diff --git a/server/ui/main.src.js b/server/ui/main.src.js index bcb736d..2f49c19 100644 --- a/server/ui/main.src.js +++ b/server/ui/main.src.js @@ -16,11 +16,11 @@ const CHAIN_IDS = { const STORAGE_KEY = "stackflow-console-config-v1"; let connectedAddress = null; -let watchtowerProducerEnabled = false; -let watchtowerProducerPrincipal = null; +let stackflowNodeCounterpartyEnabled = false; +let stackflowNodeCounterpartyPrincipal = null; const ids = { - watchtowerUrl: "watchtower-url", + serverUrl: "stackflow-node-url", contractId: "contract-id", network: "network", contractVersion: "contract-version", @@ -209,9 +209,9 @@ const ACTION_DEFS = { "field-sig-my-signature", ], }, - "request-producer-transfer": { - submitLabel: "Request producer transfer signature", - help: "Send your transfer signature to the producer and receive their signature.", + "request-counterparty-transfer": { + submitLabel: "Request counterparty transfer signature", + help: "Send your transfer signature to the counterparty and receive their signature.", fields: [ "field-sig-with", "field-sig-token", @@ -225,9 +225,9 @@ const ACTION_DEFS = { "field-sig-their-signature", ], }, - "request-producer-deposit": { - submitLabel: "Request producer deposit signature", - help: "Send your deposit signature to the producer and receive their signature.", + "request-counterparty-deposit": { + submitLabel: "Request counterparty deposit signature", + help: "Send your deposit signature to the counterparty and receive their signature.", amountLabel: "deposit Amount", fields: [ "field-sig-with", @@ -243,9 +243,9 @@ const ACTION_DEFS = { "field-sig-their-signature", ], }, - "request-producer-withdrawal": { - submitLabel: "Request producer withdrawal signature", - help: "Send your withdrawal signature to the producer and receive their signature.", + "request-counterparty-withdrawal": { + submitLabel: "Request counterparty withdrawal signature", + help: "Send your withdrawal signature to the counterparty and receive their signature.", amountLabel: "withdraw Amount", fields: [ "field-sig-with", @@ -261,9 +261,9 @@ const ACTION_DEFS = { "field-sig-their-signature", ], }, - "request-producer-close": { - submitLabel: "Request producer close signature", - help: "Send your close signature to the producer and receive their signature.", + "request-counterparty-close": { + submitLabel: "Request counterparty close signature", + help: "Send your close signature to the counterparty and receive their signature.", fields: [ "field-sig-with", "field-sig-token", @@ -277,7 +277,7 @@ const ACTION_DEFS = { }, "submit-signature-state": { submitLabel: "Submit signature state", - help: "Send the latest signed state to the watchtower.", + help: "Send the latest signed state to the server.", fields: [ "field-sig-with", "field-sig-token", @@ -295,20 +295,20 @@ const ACTION_DEFS = { }; const PRODUCER_ACTION_CONFIG = { - "request-producer-transfer": { - endpoint: "/producer/transfer", + "request-counterparty-transfer": { + endpoint: "/counterparty/transfer", action: "1", }, - "request-producer-close": { - endpoint: "/producer/signature-request", + "request-counterparty-close": { + endpoint: "/counterparty/signature-request", action: "0", }, - "request-producer-deposit": { - endpoint: "/producer/signature-request", + "request-counterparty-deposit": { + endpoint: "/counterparty/signature-request", action: "2", }, - "request-producer-withdrawal": { - endpoint: "/producer/signature-request", + "request-counterparty-withdrawal": { + endpoint: "/counterparty/signature-request", action: "3", }, }; @@ -342,10 +342,10 @@ function setSignedActionForSelection(action) { "sign-transfer": "1", "sign-deposit": "2", "sign-withdrawal": "3", - "request-producer-close": "0", - "request-producer-transfer": "1", - "request-producer-deposit": "2", - "request-producer-withdrawal": "3", + "request-counterparty-close": "0", + "request-counterparty-transfer": "1", + "request-counterparty-deposit": "2", + "request-counterparty-withdrawal": "3", }; const value = mapping[action]; @@ -354,12 +354,12 @@ function setSignedActionForSelection(action) { } } -function getProducerActionConfig(action) { +function getCounterpartyActionConfig(action) { return PRODUCER_ACTION_CONFIG[action] || null; } -function isProducerRequestAction(action) { - return Boolean(getProducerActionConfig(action)); +function isCounterpartyRequestAction(action) { + return Boolean(getCounterpartyActionConfig(action)); } function updateActionUi() { @@ -404,24 +404,24 @@ function updateActionUi() { } if ( - isProducerRequestAction(action) && + isCounterpartyRequestAction(action) && !normalizedText(getInput(ids.sigWith).value) && - watchtowerProducerPrincipal + stackflowNodeCounterpartyPrincipal ) { - getInput(ids.sigWith).value = watchtowerProducerPrincipal; + getInput(ids.sigWith).value = stackflowNodeCounterpartyPrincipal; } - let producerHint = ""; - if (isProducerRequestAction(action)) { - if (watchtowerProducerEnabled && watchtowerProducerPrincipal) { - producerHint = ` Producer principal: ${watchtowerProducerPrincipal}.`; + let counterpartyHint = ""; + if (isCounterpartyRequestAction(action)) { + if (stackflowNodeCounterpartyEnabled && stackflowNodeCounterpartyPrincipal) { + counterpartyHint = ` Counterparty principal: ${stackflowNodeCounterpartyPrincipal}.`; } else { - producerHint = - " Producer signing is not reported as enabled by the watchtower."; + counterpartyHint = + " Counterparty signing is not reported as enabled by the server."; } } - setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${producerHint}`, false); + setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${counterpartyHint}`, false); setSignedActionForSelection(action); } @@ -497,7 +497,7 @@ function makePostConditionForTransfer(principal, tokenContractId, amount) { function saveConfig() { const data = { - watchtowerUrl: getInput(ids.watchtowerUrl).value.trim(), + serverUrl: getInput(ids.serverUrl).value.trim(), contractId: getInput(ids.contractId).value.trim(), network: getInput(ids.network).value.trim(), contractVersion: getInput(ids.contractVersion).value.trim(), @@ -513,8 +513,8 @@ function loadConfig() { try { const parsed = JSON.parse(raw); - if (typeof parsed.watchtowerUrl === "string") { - getInput(ids.watchtowerUrl).value = parsed.watchtowerUrl; + if (typeof parsed.serverUrl === "string") { + getInput(ids.serverUrl).value = parsed.serverUrl; } if (typeof parsed.contractId === "string") { getInput(ids.contractId).value = parsed.contractId; @@ -531,7 +531,7 @@ function loadConfig() { } function defaultConfig() { - getInput(ids.watchtowerUrl).value = window.location.origin; + getInput(ids.serverUrl).value = window.location.origin; getInput(ids.contractVersion).value = "0.6.0"; } @@ -916,7 +916,7 @@ function extractTxid(response) { return null; } -function buildWatchtowerPayload() { +function buildStackflowNodePayload() { const parsed = parseSignerInputs(); const contractId = parseContractId(); const mySignature = normalizeHex( @@ -953,10 +953,10 @@ function buildWatchtowerPayload() { }; } -function buildProducerRequestPayload(action) { - const config = getProducerActionConfig(action); +function buildCounterpartyRequestPayload(action) { + const config = getCounterpartyActionConfig(action); if (!config) { - throw new Error(`Unsupported producer action: ${action}`); + throw new Error(`Unsupported counterparty action: ${action}`); } if (!connectedAddress) { @@ -1018,25 +1018,25 @@ function renderPayloadPreview() { return; } - if (isProducerRequestAction(action)) { + if (isCounterpartyRequestAction(action)) { try { - const request = buildProducerRequestPayload(action); + const request = buildCounterpartyRequestPayload(action); $(ids.signaturePayload).textContent = JSON.stringify(request, null, 2); } catch (error) { $(ids.signaturePayload).textContent = - error instanceof Error ? error.message : "invalid producer request"; + error instanceof Error ? error.message : "invalid counterparty request"; } return; } if (action !== "submit-signature-state") { $(ids.signaturePayload).textContent = - "Payload preview appears for submit-signature-state and producer requests."; + "Payload preview appears for submit-signature-state and counterparty requests."; return; } try { - const payload = buildWatchtowerPayload(); + const payload = buildStackflowNodePayload(); $(ids.signaturePayload).textContent = JSON.stringify(payload, null, 2); } catch (error) { $(ids.signaturePayload).textContent = @@ -1187,9 +1187,9 @@ function pipeMatchesParticipants(pipe, connected, withPrincipal, token) { } async function resolvePipeTotals(withPrincipal, token) { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } const body = await fetchJson( @@ -1222,9 +1222,9 @@ async function refreshPipes() { } try { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } const [pipeBody, closureBody] = await Promise.all([ @@ -1391,10 +1391,10 @@ async function signStructuredState() { async function submitSignatureState() { try { - const payload = buildWatchtowerPayload(); - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); + const payload = buildStackflowNodePayload(); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } const response = await fetch(`${baseUrl}/signature-states`, { @@ -1438,13 +1438,13 @@ async function submitSignatureState() { } } -async function requestProducerSignature(action) { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); +async function requestCounterpartySignature(action) { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { - throw new Error("Watchtower URL is required"); + throw new Error("Server URL is required"); } - const requestPayload = buildProducerRequestPayload(action); + const requestPayload = buildCounterpartyRequestPayload(action); const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { method: "POST", headers: { "content-type": "application/json" }, @@ -1458,7 +1458,7 @@ async function requestProducerSignature(action) { const existingNonce = body?.existingNonce ?? body?.state?.nonce ?? "?"; setStatus( ids.txResult, - `Producer request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + `Counterparty request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, true, ); return; @@ -1472,23 +1472,23 @@ async function requestProducerSignature(action) { throw new Error(message); } - const producerSignature = normalizeHex( + const counterpartySignature = normalizeHex( body?.mySignature, - "Producer signature", + "Counterparty signature", 65, ); - getInput(ids.sigTheirSignature).value = producerSignature; + getInput(ids.sigTheirSignature).value = counterpartySignature; renderPayloadPreview(); setStatus( ids.txResult, - `Producer signature received (stored=${body.stored}, replaced=${body.replaced}).`, + `Counterparty signature received (stored=${body.stored}, replaced=${body.replaced}).`, ); await refreshPipes(); } function bindInputs() { const configIds = [ - ids.watchtowerUrl, + ids.serverUrl, ids.contractId, ids.network, ids.contractVersion, @@ -1496,8 +1496,8 @@ function bindInputs() { for (const id of configIds) { getInput(id).addEventListener("change", saveConfig); } - getInput(ids.watchtowerUrl).addEventListener("change", async () => { - await syncNetworkFromWatchtower(); + getInput(ids.serverUrl).addEventListener("change", async () => { + await syncNetworkFromStackflowNode(); }); getInput(ids.actionSelect).addEventListener("change", () => { updateActionUi(); @@ -1554,19 +1554,19 @@ function normalizeNetworkName(value) { return null; } -async function syncNetworkFromWatchtower() { - const baseUrl = normalizedText(getInput(ids.watchtowerUrl).value); +async function syncNetworkFromStackflowNode() { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); if (!baseUrl) { return; } try { const health = await fetchJson(`${baseUrl}/health`); - watchtowerProducerEnabled = Boolean(health?.producerEnabled); - watchtowerProducerPrincipal = - typeof health?.producerPrincipal === "string" && - normalizedText(health.producerPrincipal) - ? health.producerPrincipal + stackflowNodeCounterpartyEnabled = Boolean(health?.counterpartyEnabled); + stackflowNodeCounterpartyPrincipal = + typeof health?.counterpartyPrincipal === "string" && + normalizedText(health.counterpartyPrincipal) + ? health.counterpartyPrincipal : null; const remoteNetwork = normalizeNetworkName(health?.stacksNetwork); if (!remoteNetwork) { @@ -1579,16 +1579,16 @@ async function syncNetworkFromWatchtower() { saveConfig(); setStatus( ids.walletStatus, - `Network auto-synced from watchtower: ${remoteNetwork}`, + `Network auto-synced from server: ${remoteNetwork}`, ); } - if (isProducerRequestAction(getSelectedAction())) { + if (isCounterpartyRequestAction(getSelectedAction())) { updateActionUi(); renderPayloadPreview(); } } catch { - // Ignore; watchtower may be offline during page load. + // Ignore; server may be offline during page load. } } @@ -1845,8 +1845,8 @@ async function executeSelectedAction() { return; } - if (isProducerRequestAction(action)) { - await requestProducerSignature(action); + if (isCounterpartyRequestAction(action)) { + await requestCounterpartySignature(action); return; } @@ -1877,7 +1877,7 @@ async function init() { bindInputs(); wireActions(); updateActionUi(); - await syncNetworkFromWatchtower(); + await syncNetworkFromStackflowNode(); await initWalletState(); if (!connectedAddress) { renderPipesPlaceholder("Connect wallet to load watched pipes."); diff --git a/tests/producer-service.test.ts b/tests/counterparty-service.test.ts similarity index 65% rename from tests/producer-service.test.ts rename to tests/counterparty-service.test.ts index a37c698..9492ad2 100644 --- a/tests/producer-service.test.ts +++ b/tests/counterparty-service.test.ts @@ -7,21 +7,26 @@ import { getAddressFromPrivateKey } from '@stacks/transactions'; import { describe, expect, it } from 'vitest'; import { - ProducerService, - ProducerServiceError, - ProducerStateSigner, -} from '../server/src/producer-service.ts'; + createCounterpartySigner, + CounterpartyService, + CounterpartyServiceError, + CounterpartyStateSigner, +} from '../server/src/counterparty-service.ts'; import { normalizePipeId } from '../server/src/observer-parser.ts'; import { canonicalPipeKey } from '../server/src/principal-utils.ts'; import { AcceptAllSignatureVerifier } from '../server/src/signature-verifier.ts'; import { SqliteStateStore } from '../server/src/state-store.ts'; -import { Watchtower } from '../server/src/watchtower.ts'; +import { StackflowNode } from '../server/src/stackflow-node.ts'; const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; const COUNTERPARTY = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; const PRODUCER_KEY = '7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801'; const COUNTERPARTY_SIGNATURE = `0x${'22'.repeat(65)}`; +const PRODUCER_ADDRESS = getAddressFromPrivateKey( + PRODUCER_KEY, + createNetwork({ network: 'devnet' }), +); function cleanupDb(store: SqliteStateStore, dbFile: string): void { store.close(); @@ -38,41 +43,41 @@ function makeHarness({ }: { signatureVerifierMode?: 'accept-all' | 'reject-all'; }) { - const producerAddress = getAddressFromPrivateKey( + const counterpartyAddress = getAddressFromPrivateKey( PRODUCER_KEY, createNetwork({ network: 'devnet' }), ); const dbFile = path.join( os.tmpdir(), - `stackflow-producer-${Date.now()}-${Math.random()}.db`, + `stackflow-counterparty-${Date.now()}-${Math.random()}.db`, ); const store = new SqliteStateStore({ dbFile, maxRecentEvents: 20 }); store.load(); - const watchtower = new Watchtower({ + const stackflowNode = new StackflowNode({ stateStore: store, - watchedPrincipals: [producerAddress], + watchedPrincipals: [counterpartyAddress], signatureVerifier: new AcceptAllSignatureVerifier(), }); - const signer = new ProducerStateSigner({ + const signer = new CounterpartyStateSigner({ stacksNetwork: 'devnet', stacksApiUrl: null, signatureVerifierMode, - producerKey: PRODUCER_KEY, - producerPrincipal: null, + counterpartyKey: PRODUCER_KEY, + counterpartyPrincipal: null, stackflowMessageVersion: '0.6.0', }); - const service = new ProducerService({ watchtower, signer }); + const service = new CounterpartyService({ stackflowNode, signer }); return { - producerAddress, + counterpartyAddress, dbFile, store, service, }; } -function transferPayload(producerAddress: string) { +function transferPayload(counterpartyAddress: string) { return { contractId: CONTRACT_ID, withPrincipal: COUNTERPARTY, @@ -82,7 +87,7 @@ function transferPayload(producerAddress: string) { theirSignature: COUNTERPARTY_SIGNATURE, nonce: '5', action: '1', - actor: producerAddress, + actor: counterpartyAddress, secret: null, validAfter: null, beneficialOnly: false, @@ -91,7 +96,7 @@ function transferPayload(producerAddress: string) { function seedObservedPipeState({ store, - producerAddress, + counterpartyAddress, withPrincipal, token, contractId, @@ -100,7 +105,7 @@ function seedObservedPipeState({ nonce, }: { store: SqliteStateStore; - producerAddress: string; + counterpartyAddress: string; withPrincipal: string; token: string | null; contractId: string; @@ -108,15 +113,15 @@ function seedObservedPipeState({ theirBalance: string; nonce: string; }): void { - const pipeKey = canonicalPipeKey(token, producerAddress, withPrincipal); + const pipeKey = canonicalPipeKey(token, counterpartyAddress, withPrincipal); const pipeId = normalizePipeId(pipeKey); if (!pipeId) { throw new Error('failed to build pipe id in test'); } - const principal1IsProducer = pipeKey['principal-1'] === producerAddress; - const balance1 = principal1IsProducer ? myBalance : theirBalance; - const balance2 = principal1IsProducer ? theirBalance : myBalance; + const principal1IsCounterparty = pipeKey['principal-1'] === counterpartyAddress; + const balance1 = principal1IsCounterparty ? myBalance : theirBalance; + const balance2 = principal1IsCounterparty ? theirBalance : myBalance; const now = new Date().toISOString(); store.setObservedPipe({ stateId: `${contractId}|${pipeId}`, @@ -139,14 +144,14 @@ function seedObservedPipeState({ }); } -describe('producer signing service', () => { +describe('counterparty signing service', () => { it('signs transfer states and stores the latest signature pair', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({}); + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); try { seedObservedPipeState({ store, - producerAddress, + counterpartyAddress, withPrincipal: COUNTERPARTY, token: null, contractId: CONTRACT_ID, @@ -155,11 +160,11 @@ describe('producer signing service', () => { nonce: '4', }); - const result = await service.signTransfer(transferPayload(producerAddress)); + const result = await service.signTransfer(transferPayload(counterpartyAddress)); expect(result.upsert.stored).toBe(true); expect(result.upsert.replaced).toBe(false); - expect(result.request.forPrincipal).toBe(producerAddress); + expect(result.request.forPrincipal).toBe(counterpartyAddress); expect(result.request.action).toBe('1'); expect(result.mySignature).toMatch(/^0x[0-9a-f]{130}$/); expect(result.upsert.state.mySignature).toBe(result.mySignature); @@ -169,16 +174,16 @@ describe('producer signing service', () => { } }); - it('enforces action restrictions on /producer/signature-request', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({}); + it('enforces action restrictions on /counterparty/signature-request', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); try { await expect( service.signSignatureRequest({ - ...transferPayload(producerAddress), + ...transferPayload(counterpartyAddress), action: '1', }), - ).rejects.toMatchObject>({ + ).rejects.toMatchObject>({ statusCode: 400, }); } finally { @@ -187,14 +192,14 @@ describe('producer signing service', () => { }); it('rejects requests when reject-all verifier mode is active', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({ + const { counterpartyAddress, dbFile, store, service } = makeHarness({ signatureVerifierMode: 'reject-all', }); try { seedObservedPipeState({ store, - producerAddress, + counterpartyAddress, withPrincipal: COUNTERPARTY, token: null, contractId: CONTRACT_ID, @@ -204,8 +209,8 @@ describe('producer signing service', () => { }); await expect( - service.signTransfer(transferPayload(producerAddress)), - ).rejects.toMatchObject>({ + service.signTransfer(transferPayload(counterpartyAddress)), + ).rejects.toMatchObject>({ statusCode: 401, }); } finally { @@ -214,12 +219,12 @@ describe('producer signing service', () => { }); it('requires amount for withdrawal signature requests', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({}); + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); try { seedObservedPipeState({ store, - producerAddress, + counterpartyAddress, withPrincipal: COUNTERPARTY, token: null, contractId: CONTRACT_ID, @@ -230,7 +235,7 @@ describe('producer signing service', () => { await expect( service.signSignatureRequest({ - ...transferPayload(producerAddress), + ...transferPayload(counterpartyAddress), action: '3', actor: COUNTERPARTY, amount: null, @@ -238,7 +243,7 @@ describe('producer signing service', () => { theirBalance: '50', nonce: '5', }), - ).rejects.toMatchObject>({ + ).rejects.toMatchObject>({ statusCode: 400, }); } finally { @@ -247,12 +252,12 @@ describe('producer signing service', () => { }); it('rejects transfer when nonce is not higher than stored state', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({}); + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); try { seedObservedPipeState({ store, - producerAddress, + counterpartyAddress, withPrincipal: COUNTERPARTY, token: null, contractId: CONTRACT_ID, @@ -261,12 +266,12 @@ describe('producer signing service', () => { nonce: '4', }); - const first = await service.signTransfer(transferPayload(producerAddress)); + const first = await service.signTransfer(transferPayload(counterpartyAddress)); expect(first.upsert.stored).toBe(true); await expect( - service.signTransfer(transferPayload(producerAddress)), - ).rejects.toMatchObject>({ + service.signTransfer(transferPayload(counterpartyAddress)), + ).rejects.toMatchObject>({ statusCode: 409, details: { reason: 'nonce-too-low', @@ -278,13 +283,13 @@ describe('producer signing service', () => { } }); - it('rejects transfer when producer balance decreases', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({}); + it('rejects transfer when counterparty balance decreases', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); try { seedObservedPipeState({ store, - producerAddress, + counterpartyAddress, withPrincipal: COUNTERPARTY, token: null, contractId: CONTRACT_ID, @@ -295,15 +300,15 @@ describe('producer signing service', () => { await expect( service.signTransfer({ - ...transferPayload(producerAddress), + ...transferPayload(counterpartyAddress), myBalance: '150', theirBalance: '150', nonce: '5', }), - ).rejects.toMatchObject>({ + ).rejects.toMatchObject>({ statusCode: 403, details: { - reason: 'producer-balance-decrease', + reason: 'counterparty-balance-decrease', }, }); } finally { @@ -312,12 +317,12 @@ describe('producer signing service', () => { }); it('rejects transfer when no baseline state exists', async () => { - const { producerAddress, dbFile, store, service } = makeHarness({}); + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); try { await expect( - service.signTransfer(transferPayload(producerAddress)), - ).rejects.toMatchObject>({ + service.signTransfer(transferPayload(counterpartyAddress)), + ).rejects.toMatchObject>({ statusCode: 409, details: { reason: 'unknown-pipe-state', @@ -327,4 +332,27 @@ describe('producer signing service', () => { cleanupDb(store, dbFile); } }); + + it('requires a KMS key id when kms signer mode is enabled', async () => { + const signer = createCounterpartySigner({ + stacksNetwork: 'devnet', + stacksApiUrl: null, + signatureVerifierMode: 'accept-all', + counterpartyKey: null, + counterpartyPrincipal: null, + counterpartySignerMode: 'kms', + stackflowMessageVersion: '0.6.0', + counterpartyKmsKeyId: null, + counterpartyKmsRegion: null, + counterpartyKmsEndpoint: null, + }); + + expect(signer.enabled).toBe(false); + await expect(signer.ensureReady()).resolves.toBeUndefined(); + await expect( + signer.signMySignature(transferPayload(PRODUCER_ADDRESS)), + ).rejects.toMatchObject>({ + statusCode: 503, + }); + }); }); diff --git a/tests/watchtower-dispute.test.ts b/tests/watchtower-dispute.test.ts index 11c568c..d22f29a 100644 --- a/tests/watchtower-dispute.test.ts +++ b/tests/watchtower-dispute.test.ts @@ -16,8 +16,8 @@ import { SqliteStateStore } from '../server/src/state-store.ts'; import { PrincipalNotWatchedError, SignatureValidationError, - Watchtower, -} from '../server/src/watchtower.ts'; + StackflowNode, +} from '../server/src/stackflow-node.ts'; import type { DisputeExecutor, SignatureVerifier, @@ -139,13 +139,13 @@ function makeStore(): { store: SqliteStateStore; dbFile: string } { describe('watchtower signature + dispute flow', () => { it('rejects signature states for unwatched principals', async () => { const { store, dbFile } = makeStore(); - const watchtower = new Watchtower({ + const stackflowNode = new StackflowNode({ stateStore: store, watchedPrincipals: [P2], }); await expect( - watchtower.upsertSignatureState({ + stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -162,20 +162,20 @@ describe('watchtower signature + dispute flow', () => { }), ).rejects.toBeInstanceOf(PrincipalNotWatchedError); - expect(watchtower.status().signatureStates).toHaveLength(0); + expect(stackflowNode.status().signatureStates).toHaveLength(0); cleanupDb(store, dbFile); }); it('rejects invalid signatures before storing state', async () => { const { store, dbFile } = makeStore(); - const watchtower = new Watchtower({ + const stackflowNode = new StackflowNode({ stateStore: store, signatureVerifier: new RejectingSignatureVerifier(), }); await expect( - watchtower.upsertSignatureState({ + stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -192,16 +192,16 @@ describe('watchtower signature + dispute flow', () => { }), ).rejects.toBeInstanceOf(SignatureValidationError); - expect(watchtower.status().signatureStates).toHaveLength(0); + expect(stackflowNode.status().signatureStates).toHaveLength(0); cleanupDb(store, dbFile); }); it('stores only the latest signature state by nonce', async () => { const { store, dbFile } = makeStore(); - const watchtower = new Watchtower({ stateStore: store }); + const stackflowNode = new StackflowNode({ stateStore: store }); - const first = await watchtower.upsertSignatureState({ + const first = await stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -220,7 +220,7 @@ describe('watchtower signature + dispute flow', () => { expect(first.stored).toBe(true); expect(first.replaced).toBe(false); - const second = await watchtower.upsertSignatureState({ + const second = await stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -238,10 +238,10 @@ describe('watchtower signature + dispute flow', () => { expect(second.stored).toBe(false); expect(second.reason).toBe('nonce-too-low'); - expect(watchtower.status().signatureStates).toHaveLength(1); - expect(watchtower.status().signatureStates[0].nonce).toBe('5'); + expect(stackflowNode.status().signatureStates).toHaveLength(1); + expect(stackflowNode.status().signatureStates[0].nonce).toBe('5'); - const third = await watchtower.upsertSignatureState({ + const third = await stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -259,9 +259,9 @@ describe('watchtower signature + dispute flow', () => { expect(third.stored).toBe(false); expect(third.reason).toBe('nonce-too-low'); - expect(watchtower.status().signatureStates).toHaveLength(1); - expect(watchtower.status().signatureStates[0].myBalance).toBe('700'); - expect(watchtower.status().signatureStates[0].nonce).toBe('5'); + expect(stackflowNode.status().signatureStates).toHaveLength(1); + expect(stackflowNode.status().signatureStates[0].myBalance).toBe('700'); + expect(stackflowNode.status().signatureStates[0].nonce).toBe('5'); cleanupDb(store, dbFile); }); @@ -269,12 +269,12 @@ describe('watchtower signature + dispute flow', () => { it('auto-disputes force-cancel with a newer signature state and avoids duplicate submissions', async () => { const { store, dbFile } = makeStore(); const executor = new FakeExecutor(); - const watchtower = new Watchtower({ + const stackflowNode = new StackflowNode({ stateStore: store, disputeExecutor: executor, }); - await watchtower.upsertSignatureState({ + await stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -299,14 +299,14 @@ describe('watchtower signature + dispute flow', () => { blockHeight: 200, }); - await watchtower.ingest(payload, '/new_block'); + await stackflowNode.ingest(payload, '/new_block'); expect(executor.calls).toHaveLength(1); expect(executor.calls[0].forPrincipal).toBe(P1); - await watchtower.ingest(payload, '/new_block'); + await stackflowNode.ingest(payload, '/new_block'); expect(executor.calls).toHaveLength(1); - const attempts = watchtower.status().disputeAttempts; + const attempts = stackflowNode.status().disputeAttempts; expect(attempts).toHaveLength(1); expect(attempts[0].success).toBe(true); @@ -316,12 +316,12 @@ describe('watchtower signature + dispute flow', () => { it('skips dispute when beneficial-only is set and state is not better for user', async () => { const { store, dbFile } = makeStore(); const executor = new FakeExecutor(); - const watchtower = new Watchtower({ + const stackflowNode = new StackflowNode({ stateStore: store, disputeExecutor: executor, }); - await watchtower.upsertSignatureState({ + await stackflowNode.upsertSignatureState({ contractId: CONTRACT_ID, forPrincipal: P1, withPrincipal: P2, @@ -338,7 +338,7 @@ describe('watchtower signature + dispute flow', () => { beneficialOnly: true, }); - await watchtower.ingest( + await stackflowNode.ingest( forceCancelPayload({ txid: '0xforce2', sender: P2, @@ -351,7 +351,7 @@ describe('watchtower signature + dispute flow', () => { ); expect(executor.calls).toHaveLength(0); - expect(watchtower.status().disputeAttempts).toHaveLength(0); + expect(stackflowNode.status().disputeAttempts).toHaveLength(0); cleanupDb(store, dbFile); }); diff --git a/tests/watchtower-http.integration.test.ts b/tests/watchtower-http.integration.test.ts index 515e82e..21a2507 100644 --- a/tests/watchtower-http.integration.test.ts +++ b/tests/watchtower-http.integration.test.ts @@ -24,7 +24,7 @@ const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; const SIG_A = `0x${'11'.repeat(65)}`; const SIG_B = `0x${'22'.repeat(65)}`; -const RUN_HTTP_INTEGRATION = process.env.WATCHTOWER_HTTP_INTEGRATION === '1'; +const RUN_HTTP_INTEGRATION = process.env.STACKFLOW_NODE_HTTP_INTEGRATION === '1'; interface Harness { baseUrl: string; @@ -42,7 +42,7 @@ beforeAll(() => { } if (!built) { - execFileSync('npm', ['run', '-s', 'build:watchtower'], { + execFileSync('npm', ['run', '-s', 'build:stackflow-node'], { cwd: ROOT, stdio: 'pipe', }); @@ -212,9 +212,9 @@ async function startHarness({ cwd: ROOT, env: { ...process.env, - WATCHTOWER_HOST: '127.0.0.1', - WATCHTOWER_PORT: String(port), - WATCHTOWER_DB_FILE: dbFile, + STACKFLOW_NODE_HOST: '127.0.0.1', + STACKFLOW_NODE_PORT: String(port), + STACKFLOW_NODE_DB_FILE: dbFile, STACKFLOW_CONTRACTS: CONTRACT_ID, ...extraEnv, }, @@ -244,9 +244,9 @@ async function startHarness({ cwd: ROOT, env: { ...process.env, - WATCHTOWER_HOST: '127.0.0.1', - WATCHTOWER_PORT: String(port), - WATCHTOWER_DB_FILE: dbFile, + STACKFLOW_NODE_HOST: '127.0.0.1', + STACKFLOW_NODE_PORT: String(port), + STACKFLOW_NODE_DB_FILE: dbFile, STACKFLOW_CONTRACTS: CONTRACT_ID, ...extraEnv, }, @@ -283,9 +283,9 @@ describeHttp('watchtower http integration', () => { const harness = await startHarness({ dbFile, extraEnv: { - WATCHTOWER_PRINCIPALS: `${P1},${P2}`, - WATCHTOWER_SIGNATURE_VERIFIER_MODE: 'accept-all', - WATCHTOWER_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_PRINCIPALS: `${P1},${P2}`, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', }, }); @@ -405,9 +405,9 @@ describeHttp('watchtower http integration', () => { const harness = await startHarness({ dbFile, extraEnv: { - WATCHTOWER_PRINCIPALS: P1, - WATCHTOWER_SIGNATURE_VERIFIER_MODE: 'accept-all', - WATCHTOWER_DISPUTE_EXECUTOR_MODE: 'mock', + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'mock', }, }); @@ -535,9 +535,9 @@ describeHttp('watchtower http integration', () => { const harness = await startHarness({ dbFile, extraEnv: { - WATCHTOWER_PRINCIPALS: P1, - WATCHTOWER_SIGNATURE_VERIFIER_MODE: 'reject-all', - WATCHTOWER_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'reject-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', }, }); diff --git a/tests/watchtower-state.test.ts b/tests/watchtower-state.test.ts index 71296bd..4c0ea1f 100644 --- a/tests/watchtower-state.test.ts +++ b/tests/watchtower-state.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest'; import { normalizePipeId } from '../server/src/observer-parser.ts'; import { SqliteStateStore } from '../server/src/state-store.ts'; -import { Watchtower } from '../server/src/watchtower.ts'; +import { StackflowNode } from '../server/src/stackflow-node.ts'; const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; @@ -86,15 +86,15 @@ describe('watchtower state transitions', () => { const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); store.load(); - const watchtower = new Watchtower({ + const stackflowNode = new StackflowNode({ stateStore: store, watchedPrincipals: [P1], }); - const result = await watchtower.ingest(payloadFor('force-close', P2, P3), '/new_block'); + const result = await stackflowNode.ingest(payloadFor('force-close', P2, P3), '/new_block'); expect(result.observedEvents).toBe(0); - expect(watchtower.status().activeClosures).toHaveLength(0); + expect(stackflowNode.status().activeClosures).toHaveLength(0); cleanupDb(store, dbFile); }); @@ -108,19 +108,19 @@ describe('watchtower state transitions', () => { const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); store.load(); - const watchtower = new Watchtower({ stateStore: store }); + const stackflowNode = new StackflowNode({ stateStore: store }); - await watchtower.ingest(payloadFor('force-close'), '/new_block'); + await stackflowNode.ingest(payloadFor('force-close'), '/new_block'); - let status = watchtower.status(); + let status = stackflowNode.status(); expect(status.activeClosures).toHaveLength(1); expect(status.activeClosures[0].event).toBe('force-close'); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].event).toBe('force-close'); - await watchtower.ingest(payloadFor('finalize'), '/new_block'); + await stackflowNode.ingest(payloadFor('finalize'), '/new_block'); - status = watchtower.status(); + status = stackflowNode.status(); expect(status.activeClosures).toHaveLength(0); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].event).toBe('finalize'); @@ -139,10 +139,10 @@ describe('watchtower state transitions', () => { const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); store.load(); - const watchtower = new Watchtower({ stateStore: store }); - await watchtower.ingest(payloadFor('fund-pipe'), '/new_block'); + const stackflowNode = new StackflowNode({ stateStore: store }); + await stackflowNode.ingest(payloadFor('fund-pipe'), '/new_block'); - const status = watchtower.status(); + const status = stackflowNode.status(); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].event).toBe('fund-pipe'); expect(status.observedPipes[0].balance1).toBe('50'); @@ -161,18 +161,18 @@ describe('watchtower state transitions', () => { const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); store.load(); - const watchtower = new Watchtower({ stateStore: store }); - await watchtower.ingest(payloadFor('force-close'), '/new_block'); + const stackflowNode = new StackflowNode({ stateStore: store }); + await stackflowNode.ingest(payloadFor('force-close'), '/new_block'); - let status = watchtower.status(); + let status = stackflowNode.status(); expect(status.activeClosures).toHaveLength(1); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].balance1).toBe('50'); expect(status.observedPipes[0].balance2).toBe('75'); - await watchtower.ingest(payloadFor('dispute-closure'), '/new_block'); + await stackflowNode.ingest(payloadFor('dispute-closure'), '/new_block'); - status = watchtower.status(); + status = stackflowNode.status(); expect(status.activeClosures).toHaveLength(0); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].event).toBe('dispute-closure'); @@ -193,18 +193,18 @@ describe('watchtower state transitions', () => { const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); store.load(); - const watchtower = new Watchtower({ stateStore: store }); - await watchtower.ingest(payloadFor('fund-pipe'), '/new_block'); + const stackflowNode = new StackflowNode({ stateStore: store }); + await stackflowNode.ingest(payloadFor('fund-pipe'), '/new_block'); - let status = watchtower.status(); + let status = stackflowNode.status(); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].event).toBe('fund-pipe'); expect(status.observedPipes[0].balance1).toBe('50'); expect(status.observedPipes[0].balance2).toBe('75'); - await watchtower.ingest(payloadFor('close-pipe'), '/new_block'); + await stackflowNode.ingest(payloadFor('close-pipe'), '/new_block'); - status = watchtower.status(); + status = stackflowNode.status(); expect(status.activeClosures).toHaveLength(0); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].event).toBe('close-pipe'); @@ -225,7 +225,7 @@ describe('watchtower state transitions', () => { const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); store.load(); - const watchtower = new Watchtower({ stateStore: store }); + const stackflowNode = new StackflowNode({ stateStore: store }); const pipeKey = { token: null, 'principal-1': P1, @@ -256,19 +256,19 @@ describe('watchtower state transitions', () => { updatedAt: new Date().toISOString(), }); - const before = await watchtower.ingestBurnBlock(158, '/new_burn_block'); + const before = await stackflowNode.ingestBurnBlock(158, '/new_burn_block'); expect(before.settledPipes).toBe(0); - let status = watchtower.status(); + let status = stackflowNode.status(); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].balance1).toBe('0'); expect(status.observedPipes[0].pending1Amount).toBe('4000000'); expect(status.observedPipes[0].pending1BurnHeight).toBe('159'); - const after = await watchtower.ingestBurnBlock(159, '/new_burn_block'); + const after = await stackflowNode.ingestBurnBlock(159, '/new_burn_block'); expect(after.settledPipes).toBe(1); - status = watchtower.status(); + status = stackflowNode.status(); expect(status.observedPipes).toHaveLength(1); expect(status.observedPipes[0].balance1).toBe('4000000'); expect(status.observedPipes[0].pending1Amount).toBeNull(); From 37221508ae065a3825923233520452c0f610e81e Mon Sep 17 00:00:00 2001 From: obycode Date: Thu, 19 Feb 2026 10:00:42 -0500 Subject: [PATCH 32/78] tests: add more server tests --- tests/counterparty-service.test.ts | 186 ++++++++++ tests/watchtower-http.integration.test.ts | 391 ++++++++++++++++++++++ 2 files changed, 577 insertions(+) diff --git a/tests/counterparty-service.test.ts b/tests/counterparty-service.test.ts index 9492ad2..c6f0d9c 100644 --- a/tests/counterparty-service.test.ts +++ b/tests/counterparty-service.test.ts @@ -94,6 +94,19 @@ function transferPayload(counterpartyAddress: string) { }; } +function signatureRequestPayload( + counterpartyAddress: string, + overrides: Record = {}, +) { + return { + ...transferPayload(counterpartyAddress), + action: '0', + amount: '0', + actor: COUNTERPARTY, + ...overrides, + }; +} + function seedObservedPipeState({ store, counterpartyAddress, @@ -251,6 +264,98 @@ describe('counterparty signing service', () => { } }); + it('requires amount for deposit signature requests', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + await expect( + service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '2', + actor: COUNTERPARTY, + amount: null, + myBalance: '200', + theirBalance: '150', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 400, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('signs close, deposit, and withdrawal signature requests', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + const closeResult = await service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '0', + amount: '0', + myBalance: '200', + theirBalance: '100', + nonce: '5', + }); + expect(closeResult.request.action).toBe('0'); + expect(closeResult.request.nonce).toBe('5'); + expect(closeResult.upsert.stored).toBe(true); + expect(closeResult.upsert.replaced).toBe(false); + + const depositResult = await service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '2', + amount: '50', + myBalance: '200', + theirBalance: '150', + nonce: '6', + }); + expect(depositResult.request.action).toBe('2'); + expect(depositResult.request.nonce).toBe('6'); + expect(depositResult.upsert.stored).toBe(true); + expect(depositResult.upsert.replaced).toBe(true); + + const withdrawalResult = await service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '3', + amount: '25', + myBalance: '200', + theirBalance: '125', + nonce: '7', + }); + expect(withdrawalResult.request.action).toBe('3'); + expect(withdrawalResult.request.nonce).toBe('7'); + expect(withdrawalResult.upsert.stored).toBe(true); + expect(withdrawalResult.upsert.replaced).toBe(true); + expect(withdrawalResult.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + } finally { + cleanupDb(store, dbFile); + } + }); + it('rejects transfer when nonce is not higher than stored state', async () => { const { counterpartyAddress, dbFile, store, service } = makeHarness({}); @@ -283,6 +388,52 @@ describe('counterparty signing service', () => { } }); + it('rejects signature requests when nonce is not higher than stored state', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + const first = await service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '0', + amount: '0', + myBalance: '200', + theirBalance: '100', + nonce: '5', + }); + expect(first.upsert.stored).toBe(true); + + await expect( + service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '0', + amount: '0', + myBalance: '200', + theirBalance: '100', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 409, + details: { + reason: 'nonce-too-low', + existingNonce: '5', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + it('rejects transfer when counterparty balance decreases', async () => { const { counterpartyAddress, dbFile, store, service } = makeHarness({}); @@ -316,6 +467,41 @@ describe('counterparty signing service', () => { } }); + it('rejects signature requests when counterparty balance decreases', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + await expect( + service.signSignatureRequest({ + ...signatureRequestPayload(counterpartyAddress), + action: '0', + amount: '0', + myBalance: '199', + theirBalance: '101', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'counterparty-balance-decrease', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + it('rejects transfer when no baseline state exists', async () => { const { counterpartyAddress, dbFile, store, service } = makeHarness({}); diff --git a/tests/watchtower-http.integration.test.ts b/tests/watchtower-http.integration.test.ts index 21a2507..ad502c1 100644 --- a/tests/watchtower-http.integration.test.ts +++ b/tests/watchtower-http.integration.test.ts @@ -22,6 +22,8 @@ const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; +const COUNTERPARTY_SIGNER_KEY = + '7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801'; const SIG_A = `0x${'11'.repeat(65)}`; const SIG_B = `0x${'22'.repeat(65)}`; const RUN_HTTP_INTEGRATION = process.env.STACKFLOW_NODE_HTTP_INTEGRATION === '1'; @@ -147,6 +149,74 @@ function signatureStatePayload(forPrincipal: string) { }; } +function transferPayload({ + forPrincipal, + withPrincipal, + myBalance, + theirBalance, + nonce, +}: { + forPrincipal: string; + withPrincipal: string; + myBalance: string; + theirBalance: string; + nonce: string; +}) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal, + token: null, + amount: '0', + myBalance, + theirBalance, + theirSignature: SIG_B, + nonce, + action: '1', + actor: withPrincipal, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +function signatureRequestPayload({ + forPrincipal, + withPrincipal, + action, + amount, + myBalance, + theirBalance, + nonce, + actor, +}: { + forPrincipal: string; + withPrincipal: string; + action: '0' | '2' | '3'; + amount: string; + myBalance: string; + theirBalance: string; + nonce: string; + actor: string; +}) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal, + token: null, + amount, + myBalance, + theirBalance, + theirSignature: SIG_B, + nonce, + action, + actor, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -527,6 +597,327 @@ describeHttp('watchtower http integration', () => { } }); + it('signs and persists a direct transfer update through /counterparty/transfer', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const transferResponse = await fetch( + `${harness.baseUrl}/counterparty/transfer`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '6', + }), + ), + }, + ); + expect(transferResponse.status).toBe(200); + const transferBody = (await transferResponse.json()) as { + stored: boolean; + replaced: boolean; + mySignature: string; + }; + expect(transferBody.stored).toBe(true); + expect(transferBody.replaced).toBe(true); + expect(transferBody.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + + const statesResponse = await fetch( + `${harness.baseUrl}/signature-states?limit=10`, + ); + expect(statesResponse.status).toBe(200); + const statesBody = (await statesResponse.json()) as { + signatureStates: Array<{ + forPrincipal: string; + withPrincipal: string; + nonce: string; + myBalance: string; + theirBalance: string; + mySignature: string; + }>; + }; + expect(statesBody.signatureStates).toHaveLength(1); + expect(statesBody.signatureStates[0].forPrincipal).toBe(counterpartyPrincipal); + expect(statesBody.signatureStates[0].withPrincipal).toBe(withPrincipal); + expect(statesBody.signatureStates[0].nonce).toBe('6'); + expect(statesBody.signatureStates[0].myBalance).toBe('910'); + expect(statesBody.signatureStates[0].theirBalance).toBe('90'); + expect(statesBody.signatureStates[0].mySignature).toBe( + transferBody.mySignature, + ); + + const pipesResponse = await fetch( + `${harness.baseUrl}/pipes?principal=${encodeURIComponent(counterpartyPrincipal)}`, + ); + expect(pipesResponse.status).toBe(200); + const pipesBody = (await pipesResponse.json()) as { + pipes: Array<{ + source: string; + nonce: string | null; + balance1: string | null; + balance2: string | null; + pipeKey: { + 'principal-1': string; + 'principal-2': string; + }; + }>; + }; + expect(pipesBody.pipes).toHaveLength(1); + expect(pipesBody.pipes[0].source).toBe('signature-state'); + expect(pipesBody.pipes[0].nonce).toBe('6'); + const principal1IsCounterparty = + pipesBody.pipes[0].pipeKey['principal-1'] === counterpartyPrincipal; + expect( + principal1IsCounterparty + ? pipesBody.pipes[0].balance1 + : pipesBody.pipes[0].balance2, + ).toBe('910'); + expect( + principal1IsCounterparty + ? pipesBody.pipes[0].balance2 + : pipesBody.pipes[0].balance1, + ).toBe('90'); + + const rejectedTransfer = await fetch( + `${harness.baseUrl}/counterparty/transfer`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '7', + }), + ), + }, + ); + expect(rejectedTransfer.status).toBe(403); + const rejectedBody = (await rejectedTransfer.json()) as { + reason: string; + }; + expect(rejectedBody.reason).toBe('transfer-not-beneficial'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('supports peer signature requests for close, deposit, and withdrawal', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const closeResponse = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '0', + amount: '0', + myBalance: '900', + theirBalance: '100', + nonce: '6', + actor: withPrincipal, + }), + ), + }, + ); + expect(closeResponse.status).toBe(200); + const closeBody = (await closeResponse.json()) as { + action: string; + nonce: string; + stored: boolean; + replaced: boolean; + mySignature: string; + }; + expect(closeBody.action).toBe('0'); + expect(closeBody.nonce).toBe('6'); + expect(closeBody.stored).toBe(true); + expect(closeBody.replaced).toBe(true); + expect(closeBody.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + + const depositResponse = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '2', + amount: '50', + myBalance: '900', + theirBalance: '150', + nonce: '7', + actor: withPrincipal, + }), + ), + }, + ); + expect(depositResponse.status).toBe(200); + const depositBody = (await depositResponse.json()) as { + action: string; + nonce: string; + }; + expect(depositBody.action).toBe('2'); + expect(depositBody.nonce).toBe('7'); + + const withdrawalResponse = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '3', + amount: '25', + myBalance: '900', + theirBalance: '125', + nonce: '8', + actor: withPrincipal, + }), + ), + }, + ); + expect(withdrawalResponse.status).toBe(200); + const withdrawalBody = (await withdrawalResponse.json()) as { + action: string; + nonce: string; + }; + expect(withdrawalBody.action).toBe('3'); + expect(withdrawalBody.nonce).toBe('8'); + + const duplicateNonce = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '0', + amount: '0', + myBalance: '900', + theirBalance: '125', + nonce: '8', + actor: withPrincipal, + }), + ), + }, + ); + expect(duplicateNonce.status).toBe(409); + const duplicateNonceBody = (await duplicateNonce.json()) as { + reason: string; + }; + expect(duplicateNonceBody.reason).toBe('nonce-too-low'); + + const balanceDecrease = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '0', + amount: '0', + myBalance: '899', + theirBalance: '126', + nonce: '9', + actor: withPrincipal, + }), + ), + }, + ); + expect(balanceDecrease.status).toBe(403); + const balanceDecreaseBody = (await balanceDecrease.json()) as { + reason: string; + }; + expect(balanceDecreaseBody.reason).toBe('counterparty-balance-decrease'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + it('returns 401 when reject-all verifier mode is active', async () => { const dbFile = path.join( os.tmpdir(), From 9558cae83acd5534ea2028fb5e5099f7212831e5 Mon Sep 17 00:00:00 2001 From: obycode Date: Sat, 28 Feb 2026 23:25:30 -0500 Subject: [PATCH 33/78] feat: server WIP --- .github/workflows/secret-scan.yml | 24 + .gitleaks.toml | 9 + README.md | 169 +- contracts/reservoir.clar | 2 +- init-stackflow.sh | 17 +- package.json | 2 +- run-with-devnet.sh | 33 +- scripts/init-stackflow.js | 12 +- server/DESIGN.md | 25 + server/SECURITY_AUDIT.md | 135 ++ server/src/config.ts | 87 +- server/src/counterparty-service.ts | 96 +- server/src/dispute-executor.ts | 6 +- server/src/forwarding-service.ts | 844 +++++++ server/src/index.ts | 1748 +++++++++++++- server/src/signature-verifier.ts | 24 +- server/src/stackflow-node.ts | 28 +- server/src/state-store.ts | 656 ++++++ server/src/types.ts | 52 + server/ui/main.js | 22 +- server/ui/main.src.js | 24 +- settings/Devnet.toml | 5 +- ...test.ts => stackflow-node-dispute.test.ts} | 0 tests/stackflow-node-http.integration.test.ts | 2047 +++++++++++++++++ ...est.ts => stackflow-node-observer.test.ts} | 0 ...e.test.ts => stackflow-node-state.test.ts} | 147 +- tests/watchtower-http.integration.test.ts | 947 -------- 27 files changed, 6080 insertions(+), 1081 deletions(-) create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .gitleaks.toml create mode 100644 server/SECURITY_AUDIT.md create mode 100644 server/src/forwarding-service.ts rename tests/{watchtower-dispute.test.ts => stackflow-node-dispute.test.ts} (100%) create mode 100644 tests/stackflow-node-http.integration.test.ts rename tests/{watchtower-observer.test.ts => stackflow-node-observer.test.ts} (100%) rename tests/{watchtower-state.test.ts => stackflow-node-state.test.ts} (66%) delete mode 100644 tests/watchtower-http.integration.test.ts diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..d73c93e --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,24 @@ +name: Secret Scan + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + gitleaks: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + with: + args: detect --source . --no-git --config .gitleaks.toml --redact diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..f55eafd --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,9 @@ +title = "stackflow-gitleaks-config" + +[allowlist] +description = "Known public test fixtures for Clarinet/devnet and deterministic test keys." +paths = [ + '''settings/Devnet\.toml''', + '''tests/stackflow-node-http\.integration\.test\.ts''', + '''tests/counterparty-service\.test\.ts''', +] diff --git a/README.md b/README.md index 9b2c096..14e9291 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ npm run stackflow-node Optional environment variables: ```bash -STACKFLOW_NODE_HOST=0.0.0.0 +STACKFLOW_NODE_HOST=127.0.0.1 STACKFLOW_NODE_PORT=8787 STACKFLOW_NODE_DB_FILE=server/data/stackflow-node-state.db STACKFLOW_NODE_MAX_RECENT_EVENTS=500 @@ -240,6 +240,20 @@ STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION=0.6.0 STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE=readonly STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE=auto STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL=false +STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE=120 +STACKFLOW_NODE_TRUST_PROXY=false +STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY=true +STACKFLOW_NODE_OBSERVER_ALLOWED_IPS=127.0.0.1,192.0.2.10 +STACKFLOW_NODE_ADMIN_READ_TOKEN= +STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY=true +STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA=true +STACKFLOW_NODE_FORWARDING_ENABLED=false +STACKFLOW_NODE_FORWARDING_MIN_FEE=0 +STACKFLOW_NODE_FORWARDING_TIMEOUT_MS=10000 +STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS=false +STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS=https://node-b.example.com,http://127.0.0.1:9797 +STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS=15000 +STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS=20 ``` If `STACKFLOW_CONTRACTS` is omitted, the stackflow-node automatically monitors any @@ -255,6 +269,42 @@ address from the KMS public key at startup. KMS mode requires the AWS KMS SDK package: `npm install @aws-sdk/client-kms`. Set `STACKFLOW_NODE_LOG_RAW_EVENTS=true` to print raw stackflow `print` event objects received via `/new_block` for payload inspection/debugging. +`STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE` applies per-IP write limits to +`POST /signature-states`, `POST /counterparty/transfer`, and +`POST /counterparty/signature-request` (`0` disables rate limiting). +`STACKFLOW_NODE_TRUST_PROXY` defaults to `false`; when enabled, the server uses +`x-forwarded-for` for client IP extraction (rate limiting and localhost checks). +`STACKFLOW_NODE_HOST` defaults to `127.0.0.1` to reduce accidental network +exposure. Use a public bind only with hardened ingress controls. +Observer ingress controls: + +- `STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY` defaults to `true` and restricts + `POST /new_block` and `POST /new_burn_block` to loopback sources. +- `STACKFLOW_NODE_OBSERVER_ALLOWED_IPS` (optional CSV) restricts observer + routes to explicit source IPs. When set, this allowlist takes precedence + over localhost-only mode. +Sensitive read controls: + +- `STACKFLOW_NODE_ADMIN_READ_TOKEN` (optional) requires this token (via + `Authorization: Bearer ...` or `x-stackflow-admin-token`) for + `GET /signature-states` and `GET /forwarding/payments`. +- `STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY` defaults to `true`; when no + admin token is configured, sensitive reads are limited to localhost sources. +- `STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA` defaults to `true`; without an + admin token, signatures and revealed secrets are redacted in response bodies. +`STACKFLOW_NODE_FORWARDING_ENABLED` enables routed transfer forwarding support. +`STACKFLOW_NODE_FORWARDING_MIN_FEE` sets the minimum forwarding spread: +`incomingAmount - outgoingAmount`. +`STACKFLOW_NODE_FORWARDING_TIMEOUT_MS` controls timeout for next-hop signing calls. +`STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS` defaults to `false`; when +`false`, forwarding destinations resolving to loopback/private/link-local/non-public +IP ranges are rejected. +`STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS` (optional) restricts allowed next-hop +base URLs for forwarding. +`STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS` controls how often pending +upstream reveal propagation retries are attempted. +`STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS` sets the maximum reveal +propagation attempts before a payment is marked `failed`. If `STACKFLOW_NODE_PRINCIPALS` is set, the stackflow-node only: 1. accepts `POST /signature-states` for `forPrincipal` values in that list @@ -275,6 +325,20 @@ Health and inspection endpoints: 5. `GET /dispute-attempts?limit=100` 6. `GET /events?limit=100` 7. `GET /app` (built-in browser UI) +8. `GET /forwarding/payments?limit=100` +9. `GET /forwarding/payments?paymentId=` + +Sensitive inspection endpoints (`/signature-states`, `/forwarding/payments`) +return `401` when an admin token is configured but missing/invalid, and return +`403` for non-local access when tokenless localhost-only mode is active. + +Public deployment hardening checklist: + +1. terminate TLS at ingress (or run end-to-end TLS/mTLS) +2. require authn/authz for external callers at ingress +3. restrict observer ingress (`STACKFLOW_NODE_OBSERVER_ALLOWED_IPS` and/or localhost-only) +4. set `STACKFLOW_NODE_ADMIN_READ_TOKEN` for sensitive read endpoints +5. keep `STACKFLOW_NODE_TRUST_PROXY=false` unless behind a trusted proxy chain Counterparty signing endpoints: @@ -287,17 +351,105 @@ Counterparty signing endpoints: For `action = 2|3` (deposit/withdraw), include `amount` in the request body. Both endpoints: -1. check local signing policy against stored state: +1. require peer-protocol headers: + - `x-stackflow-protocol-version: 1` + - `x-stackflow-request-id: <8-128 chars [a-zA-Z0-9._:-]>` + - `idempotency-key: <8-128 chars [a-zA-Z0-9._:-]>` +2. enforce idempotency per endpoint: + - same `idempotency-key` + same payload replays the original response + - same `idempotency-key` + different payload returns `409` (`idempotency-key-reused`) + - idempotency records are retention-pruned (24h TTL and capped history) +3. check local signing policy against stored state: - reject if nonce is not strictly higher than latest known nonce - reject if counterparty balance would decrease - for transfer (`action = 1`), require counterparty balance to strictly increase -2. verify the counterparty signature (`verify-signature-request`) -3. generate the counterparty signature -4. store the full signature pair via the existing signature-state pipeline +4. verify the counterparty signature (`verify-signature-request`) +5. generate the counterparty signature +6. store the full signature pair via the existing signature-state pipeline + +Responses from counterparty endpoints include: + +- `protocolVersion` +- `requestId` (echoed from request header) +- `idempotencyKey` (echoed from request header) +- `processedAt` (server timestamp) + +If the per-IP write limit is exceeded, these endpoints return `429` with +`reason = "rate-limit-exceeded"` and a `Retry-After` header. + +Forwarding endpoint: + +1. `POST /forwarding/transfer` +2. `POST /forwarding/reveal` + +`/forwarding/transfer` coordinates a routed transfer by: + +1. requesting a downstream signature from the configured next hop (`outgoing`) +2. signing the upstream state locally (`incoming`) +3. enforcing `incomingAmount - outgoingAmount >= STACKFLOW_NODE_FORWARDING_MIN_FEE` +4. requiring a 32-byte `hashedSecret` for lock/reveal tracking +5. persisting forwarding payment outcomes in SQLite +6. retaining only the latest nonce record per `(contractId, pipeId)` in forwarding history + +Request body shape: + +```json +{ + "paymentId": "pay-2026-02-28-0001", + "incomingAmount": "1000", + "outgoingAmount": "950", + "hashedSecret": "0x...", + "upstream": { + "baseUrl": "http://127.0.0.1:8787", + "paymentId": "upstream-pay-0001", + "revealEndpoint": "/forwarding/reveal" + }, + "incoming": { "...": "same payload as /counterparty/transfer" }, + "outgoing": { + "baseUrl": "http://127.0.0.1:9797", + "endpoint": "/counterparty/transfer", + "payload": { "...": "payload sent to next hop counterparty endpoint" } + } +} +``` + +`POST /forwarding/transfer` requires the same peer protocol headers as +counterparty endpoints. +For SSRF hardening, only these forwarding API paths are supported: + +- downstream next-hop endpoint: `/counterparty/transfer` +- upstream reveal endpoint: `/forwarding/reveal` + +Custom endpoint paths are rejected. + +`POST /forwarding/reveal` request body: + +```json +{ + "paymentId": "pay-2026-02-28-0001", + "secret": "0x...32-byte-preimage" +} +``` + +The server checks `sha256(secret) == hashedSecret` for that payment and stores +the revealed secret for later dispute/finality workflows. +If an `upstream` route is stored on that payment, the server immediately tries +to propagate the reveal upstream and persists retry state in SQLite: + +1. `revealPropagationStatus = pending|propagated|failed|not-applicable` +2. `revealPropagationAttempts` increments on each upstream attempt +3. `revealNextRetryAt` schedules the next retry timestamp for pending records +4. background retries resume automatically on process restart + +Forwarding retention notes: + +1. forwarding payment history keeps only the latest nonce entry per pipe +2. older nonce entries are pruned once newer nonce data is stored for that pipe +3. revealed-secret resolution is retained separately for dispute/recovery lookups -Counterparty signature verification uses `verify-signature-request` (read-only) to -apply action-aware on-chain balance logic, including `amount` checks for -deposit/withdraw requests. +Counterparty signature verification uses `verify-signature` for transfer actions +and `verify-signature-request` for close/deposit/withdraw actions, preserving +on-chain validation semantics for each action type. Signature state ingestion endpoint: @@ -332,6 +484,7 @@ request returns `401` and nothing is stored. If the incoming nonce is not strictly higher than the stored nonce for that `(contract, pipe, forPrincipal)`, the request returns `409`. If `forPrincipal` is not in the effective watchlist, the request returns `403`. +If the per-IP write limit is exceeded, the request returns `429`. On-chain pipe tracking: diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index dceca1c..0e585c7 100644 --- a/contracts/reservoir.clar +++ b/contracts/reservoir.clar @@ -4,7 +4,7 @@ ;; ;; MIT License ;; -;; Copyright (c) 2025 obycode, LLC +;; Copyright (c) 2025-2026 obycode, LLC ;; ;; Permission is hereby granted, free of charge, to any person obtaining a copy ;; of this software and associated documentation files (the "Software"), to deal diff --git a/init-stackflow.sh b/init-stackflow.sh index 823c069..aeec695 100755 --- a/init-stackflow.sh +++ b/init-stackflow.sh @@ -1,5 +1,12 @@ -STACKS_NETWORK=devnet \ -STACKS_API_URL=http://localhost:3999 \ -DEPLOYER_PRIVATE_KEY=753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601 \ -STACKFLOW_CONTRACT_ID=ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow \ -npm run init:stackflow \ No newline at end of file +#!/usr/bin/env bash +set -euo pipefail + +# Non-test usage: export a deployer key from your environment. +# export DEPLOYER_PRIVATE_KEY=... + +: "${DEPLOYER_PRIVATE_KEY:?DEPLOYER_PRIVATE_KEY is required}" + +STACKS_NETWORK="${STACKS_NETWORK:-devnet}" \ +STACKS_API_URL="${STACKS_API_URL:-http://localhost:3999}" \ +STACKFLOW_CONTRACT_ID="${STACKFLOW_CONTRACT_ID:-ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow}" \ +npm run init:stackflow diff --git a/package.json b/package.json index 4518c7d..6ca99ab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "init:stackflow": "node scripts/init-stackflow.js", "build:ui": "node scripts/build-ui.js", "test": "vitest run", - "test:stackflow-node:http": "STACKFLOW_NODE_HTTP_INTEGRATION=1 vitest run tests/watchtower-http.integration.test.ts", + "test:stackflow-node:http": "STACKFLOW_NODE_HTTP_INTEGRATION=1 vitest run tests/stackflow-node-http.integration.test.ts", "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"", "build:stackflow-node": "tsc -p tsconfig.server.json", diff --git a/run-with-devnet.sh b/run-with-devnet.sh index 42d30e4..5d39a12 100755 --- a/run-with-devnet.sh +++ b/run-with-devnet.sh @@ -1,15 +1,20 @@ -STACKFLOW_NODE_HOST=0.0.0.0 \ -STACKFLOW_NODE_PORT=8787 \ -STACKS_NETWORK=devnet \ -STACKS_API_URL=http://localhost:3999 \ -STACKFLOW_CONTRACTS=ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow \ -STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE=readonly \ -STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE=auto \ -STACKFLOW_NODE_DISPUTE_SIGNER_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ -STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE=kms \ -npm run stackflow-node +#!/usr/bin/env bash +set -euo pipefail + +# Non-test usage: do not commit live keys. Export these before running: +# export STACKFLOW_NODE_DISPUTE_SIGNER_KEY=... +# Optional local-key mode: +# export STACKFLOW_NODE_COUNTERPARTY_KEY=... +# export STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL=ST... -# STACKFLOW_NODE_LOG_RAW_EVENTS=true \ -# STACKFLOW_NODE_PRINCIPALS=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ -# STACKFLOW_NODE_COUNTERPARTY_KEY=f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 \ -# STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL=ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND \ +: "${STACKFLOW_NODE_DISPUTE_SIGNER_KEY:?STACKFLOW_NODE_DISPUTE_SIGNER_KEY is required}" + +STACKFLOW_NODE_HOST="${STACKFLOW_NODE_HOST:-127.0.0.1}" \ +STACKFLOW_NODE_PORT="${STACKFLOW_NODE_PORT:-8787}" \ +STACKS_NETWORK="${STACKS_NETWORK:-devnet}" \ +STACKS_API_URL="${STACKS_API_URL:-http://localhost:3999}" \ +STACKFLOW_CONTRACTS="${STACKFLOW_CONTRACTS:-ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow}" \ +STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE="${STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE:-readonly}" \ +STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE="${STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE:-auto}" \ +STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE="${STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE:-kms}" \ +npm run stackflow-node diff --git a/scripts/init-stackflow.js b/scripts/init-stackflow.js index d95a792..2a1d818 100644 --- a/scripts/init-stackflow.js +++ b/scripts/init-stackflow.js @@ -11,9 +11,6 @@ import { noneCV, } from "@stacks/transactions"; -const DEFAULT_DEVNET_DEPLOYER_KEY = - "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601"; - function normalizePrivateKey(input) { const trimmed = input.trim(); return trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed; @@ -48,11 +45,10 @@ async function main() { ? "https://api.testnet.hiro.so" : "http://localhost:20443"); - const deployerKeyInput = - process.env.DEPLOYER_PRIVATE_KEY?.trim() || DEFAULT_DEVNET_DEPLOYER_KEY; - if (!process.env.DEPLOYER_PRIVATE_KEY?.trim()) { - console.warn( - "[init-stackflow] DEPLOYER_PRIVATE_KEY not set; using default Clarinet devnet deployer key", + const deployerKeyInput = process.env.DEPLOYER_PRIVATE_KEY?.trim(); + if (!deployerKeyInput) { + throw new Error( + "DEPLOYER_PRIVATE_KEY is required; refusing to use embedded fixture keys", ); } diff --git a/server/DESIGN.md b/server/DESIGN.md index 232d76c..1c59d52 100644 --- a/server/DESIGN.md +++ b/server/DESIGN.md @@ -49,6 +49,12 @@ On startup the store configures: - `dispute_attempts(attempt_id PRIMARY KEY, ...)` - Index: `created_at DESC` - `recent_events(seq INTEGER PRIMARY KEY AUTOINCREMENT, event_json, observed_at)` +- `idempotent_responses((endpoint,idempotency_key) PRIMARY KEY, ...)` + - Index: `created_at DESC` +- `forwarding_payments(payment_id PRIMARY KEY, contract_id, pipe_id, pipe_nonce, ...)` + - Indexes: `(updated_at DESC)`, `(contract_id, pipe_id, updated_at DESC)`, + `(reveal_propagation_status, reveal_next_retry_at)` +- `revealed_secrets(hashed_secret PRIMARY KEY, revealed_secret, ...)` ### Logical keys @@ -61,6 +67,10 @@ On startup the store configures: - Every write updates `meta.updated_at`. - `recent_events` is capped by `STACKFLOW_NODE_MAX_RECENT_EVENTS` (default `500`). - `recent_events` is pruned after each insert. +- `idempotent_responses` is retention-pruned (TTL + max-row cap). +- `forwarding_payments` keeps only the latest nonce entry per `(contract_id, pipe_id)`. +- `revealed_secrets` persists hashed-secret to revealed-secret resolution for + dispute/recovery lookups after forwarding-history pruning. ## API @@ -168,9 +178,24 @@ Deduping: - `STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE` (`readonly|accept-all|reject-all`) - `STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE` (`auto|noop|mock`) - `STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL` +- `STACKFLOW_NODE_TRUST_PROXY` (default `false`) +- `STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY` (default `true`) +- `STACKFLOW_NODE_OBSERVER_ALLOWED_IPS` (CSV source allowlist for observer routes) +- `STACKFLOW_NODE_ADMIN_READ_TOKEN` (optional admin token for sensitive reads) +- `STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY` (default `true`) +- `STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA` (default `true`) +- `STACKFLOW_NODE_FORWARDING_ENABLED` +- `STACKFLOW_NODE_FORWARDING_MIN_FEE` +- `STACKFLOW_NODE_FORWARDING_TIMEOUT_MS` +- `STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS` (default `false`) +- `STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS` +- `STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS` +- `STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS` ## Production Notes +- Default bind host is `127.0.0.1`; expose publicly only behind hardened ingress. +- For non-local deployments, terminate TLS and enforce authn/authz at ingress. - SQLite provides transactional durability and better recovery than JSON file snapshots. - WAL mode improves write reliability and read concurrency for status endpoints. - This implementation uses `node:sqlite`; on current Node versions it may emit an experimental warning. diff --git a/server/SECURITY_AUDIT.md b/server/SECURITY_AUDIT.md new file mode 100644 index 0000000..2ad7332 --- /dev/null +++ b/server/SECURITY_AUDIT.md @@ -0,0 +1,135 @@ +# Stackflow Server Security Audit Tracker + +Last updated: 2026-03-01 + +This file tracks server-side security findings, agreed requirements, and remediation status. + +## Agreed Requirement (2026-03-01) + +- `SFSEC-001` observer ingress restriction: + `POST /new_block` and `POST /new_burn_block` must only accept traffic from a trusted source IP set (or optionally localhost-only mode). The expected deployment is one trusted Stacks node, often on the same machine. + +## Findings + +| ID | Severity | Status | Finding | +| --- | --- | --- | --- | +| SFSEC-001 | Critical | In Progress | Unauthenticated observer endpoints (`/new_block`, `/new_burn_block`) can be abused to poison state and trigger dispute submissions. | +| SFSEC-002 | Critical | In Progress | SSRF risk in forwarding flow (user-controlled next-hop/upstream URLs) with downstream body reflection. | +| SFSEC-003 | High | In Progress | Unauthenticated read endpoints leak sensitive data (`/signature-states`, `/forwarding/payments`, including revealed secrets/signatures). | +| SFSEC-004 | High | In Progress | Rate limiting trusts `x-forwarded-for`, allowing spoof-based bypass and memory pressure. | +| SFSEC-005 | Medium | In Progress | Unbounded growth in persisted idempotency/payment records enables storage DoS. | +| SFSEC-006 | Medium | In Progress | Default bind to `0.0.0.0` + no built-in transport/auth hardening increases exposure risk. | +| SFSEC-007 | Low | In Progress | Dev/private key material and mnemonics are committed in repo dev files/scripts. | + +## Remediation Notes + +### SFSEC-001 (observer ingress restriction) + +Proposed controls: + +1. Add source restriction for observer routes: + - allowlist env var for source IPs/CIDRs, and/or + - explicit localhost-only mode. +2. Reject non-allowlisted sources before payload parsing. +3. Ensure `x-forwarded-for` is only honored when explicitly behind trusted proxy mode. +4. Add integration tests for: + - allowed local source + - denied non-allowlisted source + - localhost-only mode behavior + +Acceptance criteria: + +- Requests to `/new_block` and `/new_burn_block` from non-allowed sources return `403`. +- Default production-safe behavior is documented. +- Tests cover positive and negative path source filtering. + +Current progress (2026-03-01): + +- Added observer source filtering with localhost-only default and explicit IP allowlist support. +- Added integration coverage for deny behavior and `x-forwarded-for` spoof resistance. + +### SFSEC-002 (SSRF and response reflection) + +Proposed controls: + +1. Enforce mandatory allowlist for outgoing next-hop and upstream reveal URLs in forwarding mode. +2. Block private/link-local/loopback destinations unless explicitly allowed. +3. Stop reflecting arbitrary downstream/upstream response bodies in error payloads. + +Current progress (2026-03-01): + +- Enforced fixed forwarding endpoint paths (`/counterparty/transfer`, `/forwarding/reveal`). +- Added strict transfer-payload shape validation before forwarding. +- Added destination hardening that blocks non-public/private egress by default, with explicit override for local/dev. +- Removed downstream/upstream response body reflection from forwarding errors. + +### SFSEC-003 (sensitive read endpoints) + +Proposed controls: + +1. Require auth for inspection endpoints or bind these endpoints to admin interface only. +2. Redact signatures and revealed secrets in default responses. + +Current progress (2026-03-01): + +- Added admin-read controls for `GET /signature-states` and `GET /forwarding/payments`: + - optional token auth (`Authorization: Bearer` or `x-stackflow-admin-token`) + - localhost-only access when token is unset (default enabled) +- Added default sensitive-field redaction for tokenless reads (signatures/secrets). +- Added integration coverage for token-required and redaction behavior. + +### SFSEC-004 (rate limit spoofing) + +Proposed controls: + +1. Do not trust `x-forwarded-for` unless trusted-proxy mode is enabled. +2. Otherwise use socket source address only. + +Current progress (2026-03-01): + +- Rate limiting now uses socket source address by default. +- Added explicit trusted-proxy mode (`STACKFLOW_NODE_TRUST_PROXY`) to opt in to + `x-forwarded-for` parsing only when intentionally deployed behind a trusted proxy. +- Added integration coverage for spoof-resistance when trusted-proxy mode is disabled. + +### SFSEC-005 (storage DoS) + +Proposed controls: + +1. Add TTL and/or row caps for `idempotent_responses` and `forwarding_payments`. +2. Add periodic pruning job and metrics/logging for table growth. + +Current progress (2026-03-01): + +- Added idempotency retention pruning (TTL + max-row cap). +- Added forwarding retention policy to keep only the latest nonce record per + `(contract_id, pipe_id)` in `forwarding_payments`. +- Added `revealed_secrets` table to preserve `hashed_secret -> revealed_secret` + resolution after forwarding payment pruning. + +### SFSEC-006 (network exposure defaults) + +Proposed controls: + +1. Use safer default bind (localhost) for local deployments. +2. Document TLS and auth requirements at ingress for non-local deployments. + +Current progress (2026-03-01): + +- Changed default bind host to `127.0.0.1`. +- Added startup warnings when binding to a public host without strict ingress controls. +- Added explicit public-deployment hardening guidance (TLS/auth/IP controls) in docs. + +### SFSEC-007 (credential hygiene) + +Proposed controls: + +1. Keep fixture-only credentials clearly marked non-production. +2. Move runnable scripts to read keys from environment for non-test usage. +3. Add secret-scanning policy in CI. + +Current progress (2026-03-01): + +- Removed embedded runnable private keys from helper scripts; scripts now require env-provided keys. +- Marked Clarinet devnet mnemonic/key material as fixture-only and non-production. +- Added CI secret scanning workflow and repository policy configuration. diff --git a/server/src/config.ts b/server/src/config.ts index 5f78c34..ff3118a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -9,9 +9,17 @@ import type { } from './types.js'; import process from 'node:process'; -const DEFAULT_HOST = '0.0.0.0'; +const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_PORT = 8787; const DEFAULT_MAX_RECENT_EVENTS = 500; +const DEFAULT_PEER_WRITE_RATE_LIMIT_PER_MINUTE = 120; +const DEFAULT_TRUST_PROXY = false; +const DEFAULT_OBSERVER_LOCALHOST_ONLY = true; +const DEFAULT_ADMIN_READ_LOCALHOST_ONLY = true; +const DEFAULT_REDACT_SENSITIVE_READ_DATA = true; +const DEFAULT_FORWARDING_TIMEOUT_MS = 10_000; +const DEFAULT_FORWARDING_REVEAL_RETRY_INTERVAL_MS = 15_000; +const DEFAULT_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS = 20; const MAX_WATCHED_PRINCIPALS = 100; const DEFAULT_DB_FILE = path.resolve( process.cwd(), @@ -68,6 +76,25 @@ function parseBoolean(value: unknown, fallback: boolean): boolean { return fallback; } +function normalizeBaseUrl(input: string): string { + let parsed: URL; + try { + parsed = new URL(input.trim()); + } catch { + throw new Error(`invalid forwarding base url: ${input}`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`forwarding base url must use http/https: ${input}`); + } + + parsed.pathname = ''; + parsed.search = ''; + parsed.hash = ''; + const normalized = parsed.toString(); + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; +} + function parseNetwork(value: unknown): 'mainnet' | 'testnet' | 'devnet' | 'mocknet' { const normalized = String(value || 'devnet').trim().toLowerCase(); if (normalized === 'mainnet' || normalized === 'testnet' || normalized === 'mocknet') { @@ -172,5 +199,63 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): StackflowNodeC env.STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL, false, ), + peerWriteRateLimitPerMinute: Math.max( + 0, + parseInteger( + env.STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE, + DEFAULT_PEER_WRITE_RATE_LIMIT_PER_MINUTE, + ), + ), + trustProxy: parseBoolean( + env.STACKFLOW_NODE_TRUST_PROXY, + DEFAULT_TRUST_PROXY, + ), + observerLocalhostOnly: parseBoolean( + env.STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY, + DEFAULT_OBSERVER_LOCALHOST_ONLY, + ), + observerAllowedIps: parseCsv(env.STACKFLOW_NODE_OBSERVER_ALLOWED_IPS), + adminReadToken: env.STACKFLOW_NODE_ADMIN_READ_TOKEN?.trim() || null, + adminReadLocalhostOnly: parseBoolean( + env.STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY, + DEFAULT_ADMIN_READ_LOCALHOST_ONLY, + ), + redactSensitiveReadData: parseBoolean( + env.STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA, + DEFAULT_REDACT_SENSITIVE_READ_DATA, + ), + forwardingEnabled: parseBoolean(env.STACKFLOW_NODE_FORWARDING_ENABLED, false), + forwardingMinFee: Math.max( + 0, + parseInteger(env.STACKFLOW_NODE_FORWARDING_MIN_FEE, 0), + ).toString(10), + forwardingTimeoutMs: Math.max( + 1_000, + parseInteger( + env.STACKFLOW_NODE_FORWARDING_TIMEOUT_MS, + DEFAULT_FORWARDING_TIMEOUT_MS, + ), + ), + forwardingAllowPrivateDestinations: parseBoolean( + env.STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS, + false, + ), + forwardingAllowedBaseUrls: parseCsv( + env.STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS, + ).map(normalizeBaseUrl), + forwardingRevealRetryIntervalMs: Math.max( + 1_000, + parseInteger( + env.STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS, + DEFAULT_FORWARDING_REVEAL_RETRY_INTERVAL_MS, + ), + ), + forwardingRevealRetryMaxAttempts: Math.max( + 1, + parseInteger( + env.STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS, + DEFAULT_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS, + ), + ), }; } diff --git a/server/src/counterparty-service.ts b/server/src/counterparty-service.ts index 8f9d45b..53874ad 100644 --- a/server/src/counterparty-service.ts +++ b/server/src/counterparty-service.ts @@ -107,6 +107,11 @@ function normalizeOptionalHexBuff( return normalizeHexBuff(input, bytes, fieldName); } +function sha256Hex(inputHex: string): string { + const bytes = Buffer.from(normalizeHex(inputHex).slice(2), 'hex'); + return `0x${createHash('sha256').update(bytes).digest('hex')}`; +} + function normalizeBool(input: unknown, fallback: boolean): boolean { if (input === undefined || input === null || input === '') { return fallback; @@ -394,7 +399,7 @@ function buildCounterpartySigningContext(request: CounterpartySignRequest): Coun : request.myBalance; const tokenArg = request.token ? someCV(principalCV(request.token)) : noneCV(); - const secretArg = request.secret + const hashedSecretArg = request.secret ? someCV(bufferCV(hexToBytes(request.secret))) : noneCV(); const validAfterArg = request.validAfter @@ -406,7 +411,7 @@ function buildCounterpartySigningContext(request: CounterpartySignRequest): Coun balance1, balance2, tokenArg, - secretArg, + secretArg: hashedSecretArg, validAfterArg, }; } @@ -482,24 +487,43 @@ async function verifyCounterpartyWithReadonly( senderAddress: senderAddressForPrincipal(counterpartyPrincipal), contractAddress: contract.address, contractName: contract.name, - functionName: 'verify-signature-request', - functionArgs: [ - bufferCV(hexToBytes(request.theirSignature)), - principalCV(request.withPrincipal), - tupleCV({ - token: context.tokenArg, - 'principal-1': principalCV(context.pipeKey['principal-1']), - 'principal-2': principalCV(context.pipeKey['principal-2']), - }), - uintCV(BigInt(context.balance1)), - uintCV(BigInt(context.balance2)), - uintCV(BigInt(request.nonce)), - uintCV(BigInt(request.action)), - principalCV(request.actor), - context.secretArg, - context.validAfterArg, - uintCV(BigInt(request.amount)), - ], + functionName: request.action === ACTION_TRANSFER + ? 'verify-signature' + : 'verify-signature-request', + functionArgs: request.action === ACTION_TRANSFER + ? [ + bufferCV(hexToBytes(request.theirSignature)), + principalCV(request.withPrincipal), + tupleCV({ + token: context.tokenArg, + 'principal-1': principalCV(context.pipeKey['principal-1']), + 'principal-2': principalCV(context.pipeKey['principal-2']), + }), + uintCV(BigInt(context.balance1)), + uintCV(BigInt(context.balance2)), + uintCV(BigInt(request.nonce)), + uintCV(BigInt(request.action)), + principalCV(request.actor), + context.secretArg, + context.validAfterArg, + ] + : [ + bufferCV(hexToBytes(request.theirSignature)), + principalCV(request.withPrincipal), + tupleCV({ + token: context.tokenArg, + 'principal-1': principalCV(context.pipeKey['principal-1']), + 'principal-2': principalCV(context.pipeKey['principal-2']), + }), + uintCV(BigInt(context.balance1)), + uintCV(BigInt(context.balance2)), + uintCV(BigInt(request.nonce)), + uintCV(BigInt(request.action)), + principalCV(request.actor), + context.secretArg, + context.validAfterArg, + uintCV(BigInt(request.amount)), + ], }); if (response.type === ClarityType.ResponseErr) { @@ -583,6 +607,25 @@ function parseCounterpartySignRequest( ? parseUIntField(data.amount, 'amount') : parseOptionalUIntField(data.amount, 'amount') || '0'; + const hashedSecret = normalizeOptionalHexBuff(data.hashedSecret, 32, 'hashedSecret'); + const rawSecret = normalizeOptionalHexBuff(data.secret, 32, 'secret'); + if (hashedSecret && rawSecret && hashedSecret !== rawSecret) { + throw new CounterpartyServiceError( + 400, + 'hashedSecret and secret must match when both are provided', + ); + } + if ( + action !== ACTION_TRANSFER && + hashedSecret && + !rawSecret + ) { + throw new CounterpartyServiceError( + 400, + 'hashedSecret is only supported for transfer actions', + ); + } + return { contractId: normalizeContractId(data.contractId), forPrincipal: options.counterpartyPrincipal, @@ -599,7 +642,18 @@ function parseCounterpartySignRequest( nonce: parseUIntField(data.nonce, 'nonce'), action, actor: parsePrincipalField(data.actor, 'actor'), - secret: normalizeOptionalHexBuff(data.secret, 32, 'secret'), + secret: (() => { + if (hashedSecret) { + return hashedSecret; + } + if (rawSecret && action === ACTION_TRANSFER) { + return rawSecret; + } + if (rawSecret) { + return sha256Hex(rawSecret); + } + return null; + })(), validAfter: parseOptionalUIntField(data.validAfter, 'validAfter'), beneficialOnly: normalizeBool(data.beneficialOnly, false), }; diff --git a/server/src/dispute-executor.ts b/server/src/dispute-executor.ts index d1d2438..f424a4b 100644 --- a/server/src/dispute-executor.ts +++ b/server/src/dispute-executor.ts @@ -55,8 +55,10 @@ export class StacksDisputeExecutor implements DisputeExecutor { async submitDispute({ signatureState, + resolvedSecret, }: { signatureState: SignatureStateRecord; + resolvedSecret: string | null; closure: ClosureRecord; triggerEvent: StackflowPrintEvent; }): Promise { @@ -70,8 +72,8 @@ export class StacksDisputeExecutor implements DisputeExecutor { ? someCV(principalCV(signatureState.token)) : noneCV(); - const secretArg = signatureState.secret - ? someCV(bufferCV(hexToBytes(signatureState.secret))) + const secretArg = resolvedSecret + ? someCV(bufferCV(hexToBytes(resolvedSecret))) : noneCV(); const validAfterArg = signatureState.validAfter diff --git a/server/src/forwarding-service.ts b/server/src/forwarding-service.ts new file mode 100644 index 0000000..1924e3a --- /dev/null +++ b/server/src/forwarding-service.ts @@ -0,0 +1,844 @@ +import { createHash } from 'node:crypto'; +import { lookup } from 'node:dns/promises'; +import net from 'node:net'; + +import { + isValidHex, + normalizeHex, + parsePrincipal, + parseUInt, + splitContractId, +} from './principal-utils.js'; +import { + CounterpartyService, + CounterpartyServiceError, + type CounterpartySignResult, +} from './counterparty-service.js'; +import type { ForwardingPaymentRecord } from './types.js'; + +const PEER_PROTOCOL_VERSION = '1'; +const MAX_ID_LENGTH = 128; +const ID_PATTERN = /^[a-zA-Z0-9._:-]+$/; +const NEXT_HOP_TRANSFER_ENDPOINT = '/counterparty/transfer'; +const UPSTREAM_REVEAL_ENDPOINT = '/forwarding/reveal'; +const TRANSFER_PAYLOAD_ALLOWED_FIELDS = new Set([ + 'contractId', + 'forPrincipal', + 'withPrincipal', + 'token', + 'amount', + 'myBalance', + 'theirBalance', + 'theirSignature', + 'counterpartySignature', + 'nonce', + 'action', + 'actor', + 'hashedSecret', + 'secret', + 'validAfter', + 'beneficialOnly', +]); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeBaseUrl(input: string): string { + let parsed: URL; + try { + parsed = new URL(input.trim()); + } catch { + throw new ForwardingServiceError(400, 'invalid-next-hop-base-url', { + reason: 'invalid-next-hop-base-url', + }); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new ForwardingServiceError(400, 'invalid-next-hop-base-url', { + reason: 'invalid-next-hop-base-url', + }); + } + + parsed.pathname = ''; + parsed.search = ''; + parsed.hash = ''; + const normalized = parsed.toString(); + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; +} + +function normalizeEndpoint(input: unknown): string { + if (input === undefined || input === null || input === '') { + return NEXT_HOP_TRANSFER_ENDPOINT; + } + if (typeof input !== 'string') { + throw new ForwardingServiceError(400, 'invalid-next-hop-endpoint', { + reason: 'invalid-next-hop-endpoint', + }); + } + const value = input.trim(); + if (!value.startsWith('/')) { + throw new ForwardingServiceError(400, 'invalid-next-hop-endpoint', { + reason: 'invalid-next-hop-endpoint', + }); + } + if (value !== NEXT_HOP_TRANSFER_ENDPOINT) { + throw new ForwardingServiceError(400, 'unsupported-next-hop-endpoint', { + reason: 'unsupported-next-hop-endpoint', + endpoint: value, + supportedEndpoint: NEXT_HOP_TRANSFER_ENDPOINT, + }); + } + return value; +} + +function normalizeRevealEndpoint(input: unknown): string { + if (input === undefined || input === null || input === '') { + return UPSTREAM_REVEAL_ENDPOINT; + } + if (typeof input !== 'string') { + throw new ForwardingServiceError(400, 'invalid-upstream-reveal-endpoint', { + reason: 'invalid-upstream-reveal-endpoint', + }); + } + const value = input.trim(); + if (!value.startsWith('/')) { + throw new ForwardingServiceError(400, 'invalid-upstream-reveal-endpoint', { + reason: 'invalid-upstream-reveal-endpoint', + }); + } + if (value !== UPSTREAM_REVEAL_ENDPOINT) { + throw new ForwardingServiceError(400, 'unsupported-upstream-reveal-endpoint', { + reason: 'unsupported-upstream-reveal-endpoint', + endpoint: value, + supportedEndpoint: UPSTREAM_REVEAL_ENDPOINT, + }); + } + return value; +} + +function normalizeId(value: unknown, fieldName: string): string { + if (typeof value !== 'string') { + throw new ForwardingServiceError(400, `${fieldName} must be a string`, { + reason: `invalid-${fieldName}`, + }); + } + const normalized = value.trim(); + if ( + normalized.length < 8 || + normalized.length > MAX_ID_LENGTH || + !ID_PATTERN.test(normalized) + ) { + throw new ForwardingServiceError( + 400, + `${fieldName} must be 8-128 chars [a-zA-Z0-9._:-]`, + { reason: `invalid-${fieldName}` }, + ); + } + return normalized; +} + +function parseAmount(value: unknown, field: string): string { + try { + return parseUInt(value); + } catch { + throw new ForwardingServiceError(400, `${field} must be a uint`, { + reason: `invalid-${field}`, + }); + } +} + +function normalizeIpAddress(value: string): string | null { + let text = value.trim(); + if (!text) { + return null; + } + + if (text.startsWith('[') && text.endsWith(']')) { + text = text.slice(1, -1); + } + + const zoneSeparator = text.indexOf('%'); + if (zoneSeparator >= 0) { + text = text.slice(0, zoneSeparator); + } + + const mappedV4Match = text.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (mappedV4Match) { + const upper = Number.parseInt(mappedV4Match[1], 16); + const lower = Number.parseInt(mappedV4Match[2], 16); + return `${(upper >> 8) & 255}.${upper & 255}.${(lower >> 8) & 255}.${lower & 255}`; + } + + if (text.toLowerCase().startsWith('::ffff:')) { + const candidate = text.slice('::ffff:'.length); + if (net.isIP(candidate) === 4) { + return candidate; + } + } + + const ipVersion = net.isIP(text); + if (ipVersion === 4) { + return text; + } + + if (ipVersion === 6) { + try { + const hostname = new URL(`http://[${text}]/`).hostname; + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return hostname.slice(1, -1).toLowerCase(); + } + } catch { + return text.toLowerCase(); + } + return text.toLowerCase(); + } + + return null; +} + +function isPrivateOrNonPublicIp(ip: string): boolean { + if (net.isIP(ip) === 4) { + const parts = ip.split('.').map((part) => Number.parseInt(part, 10)); + if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) { + return true; + } + const [a, b] = parts; + if (a === 10 || a === 127 || a === 0) { + return true; + } + if (a === 169 && b === 254) { + return true; + } + if (a === 172 && b >= 16 && b <= 31) { + return true; + } + if (a === 192 && b === 168) { + return true; + } + if (a === 100 && b >= 64 && b <= 127) { + return true; + } + if (a === 198 && (b === 18 || b === 19)) { + return true; + } + if (a >= 224) { + return true; + } + return false; + } + + if (net.isIP(ip) === 6) { + if (ip === '::' || ip === '::1') { + return true; + } + if (ip.startsWith('fc') || ip.startsWith('fd')) { + return true; + } + if ( + ip.startsWith('fe8') || + ip.startsWith('fe9') || + ip.startsWith('fea') || + ip.startsWith('feb') + ) { + return true; + } + if (ip.startsWith('ff')) { + return true; + } + return false; + } + + return true; +} + +async function resolveHostnameIps(hostname: string): Promise { + const direct = normalizeIpAddress(hostname); + if (direct) { + return [direct]; + } + + const resolved = await lookup(hostname, { + all: true, + verbatim: true, + }); + + const unique = new Set(); + for (const entry of resolved) { + const normalized = normalizeIpAddress(entry.address); + if (normalized) { + unique.add(normalized); + } + } + + return [...unique]; +} + +async function enforcePublicDestination( + baseUrl: string, + allowPrivateDestinations: boolean, + destinationLabel: 'next-hop' | 'upstream-reveal', +): Promise { + if (allowPrivateDestinations) { + return; + } + + const parsed = new URL(baseUrl); + if (parsed.hostname.toLowerCase() === 'localhost') { + throw new ForwardingServiceError(403, `${destinationLabel} destination must be public`, { + reason: `${destinationLabel}-private-destination`, + }); + } + + let ips: string[]; + try { + ips = await resolveHostnameIps(parsed.hostname); + } catch { + throw new ForwardingServiceError(502, `${destinationLabel} hostname resolution failed`, { + reason: `${destinationLabel}-dns-failed`, + }); + } + + if (ips.length === 0) { + throw new ForwardingServiceError(502, `${destinationLabel} hostname resolution failed`, { + reason: `${destinationLabel}-dns-failed`, + }); + } + + if (ips.some((ip) => isPrivateOrNonPublicIp(ip))) { + throw new ForwardingServiceError(403, `${destinationLabel} destination must be public`, { + reason: `${destinationLabel}-private-destination`, + }); + } +} + +function validateTransferPayloadShape( + payload: Record, + label: string, +): void { + for (const key of Object.keys(payload)) { + if (!TRANSFER_PAYLOAD_ALLOWED_FIELDS.has(key)) { + throw new ForwardingServiceError(400, `${label} contains unsupported field: ${key}`, { + reason: 'invalid-transfer-payload', + }); + } + } + + if (typeof payload.contractId !== 'string' || payload.contractId.trim() === '') { + throw new ForwardingServiceError(400, `${label}.contractId is required`, { + reason: 'invalid-transfer-payload', + }); + } + try { + splitContractId(payload.contractId.trim()); + } catch { + throw new ForwardingServiceError(400, `${label}.contractId is invalid`, { + reason: 'invalid-transfer-payload', + }); + } + + try { + parsePrincipal(payload.forPrincipal, `${label}.forPrincipal`); + parsePrincipal(payload.withPrincipal, `${label}.withPrincipal`); + parsePrincipal(payload.actor, `${label}.actor`); + } catch (error) { + throw new ForwardingServiceError( + 400, + error instanceof Error ? error.message : `${label} has invalid principal fields`, + { reason: 'invalid-transfer-payload' }, + ); + } + + parseAmount(payload.myBalance, `${label}.myBalance`); + parseAmount(payload.theirBalance, `${label}.theirBalance`); + parseAmount(payload.nonce, `${label}.nonce`); + + const action = parseAmount( + payload.action === undefined ? '1' : payload.action, + `${label}.action`, + ); + if (action !== '1') { + throw new ForwardingServiceError(400, `${label}.action must be 1`, { + reason: 'invalid-transfer-payload', + }); + } + + if (payload.amount !== undefined) { + parseAmount(payload.amount, `${label}.amount`); + } + if (payload.validAfter !== undefined && payload.validAfter !== null && payload.validAfter !== '') { + parseAmount(payload.validAfter, `${label}.validAfter`); + } + if (payload.token !== undefined && payload.token !== null && payload.token !== '') { + try { + parsePrincipal(payload.token, `${label}.token`); + } catch (error) { + throw new ForwardingServiceError( + 400, + error instanceof Error ? error.message : `${label}.token is invalid`, + { reason: 'invalid-transfer-payload' }, + ); + } + } + + const signatureValue = + typeof payload.theirSignature === 'string' + ? payload.theirSignature + : payload.counterpartySignature; + if (typeof signatureValue !== 'string' || !isValidHex(signatureValue, 65)) { + throw new ForwardingServiceError(400, `${label}.theirSignature must be 65-byte hex`, { + reason: 'invalid-transfer-payload', + }); + } +} + +function buildProtocolSeed(paymentId: string): string { + const timestamp = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 10); + return `${paymentId.slice(0, 32)}-${timestamp}-${rand}`.slice(0, MAX_ID_LENGTH); +} + +interface ForwardTransferRequest { + paymentId: string; + incomingAmount: string; + outgoingAmount: string; + hashedSecret: string; + nextHopBaseUrl: string; + nextHopEndpoint: string; + nextHopPayload: Record; + incomingPayload: Record; + upstreamBaseUrl: string | null; + upstreamRevealEndpoint: string | null; + upstreamPaymentId: string | null; +} + +export interface ForwardTransferResult { + paymentId: string; + incomingAmount: string; + outgoingAmount: string; + feeAmount: string; + hashedSecret: string; + nextHopBaseUrl: string; + nextHopEndpoint: string; + upstreamBaseUrl: string | null; + upstreamRevealEndpoint: string | null; + upstreamPaymentId: string | null; + incomingResult: CounterpartySignResult; + nextHopResponse: Record; +} + +function normalizeHashedSecret(value: unknown): string { + if (typeof value !== 'string' || value.trim() === '') { + throw new ForwardingServiceError(400, 'hashedSecret is required', { + reason: 'missing-hashed-secret', + }); + } + + const normalized = normalizeHex(value); + if (!isValidHex(normalized, 32)) { + throw new ForwardingServiceError(400, 'hashedSecret must be 32-byte hex', { + reason: 'invalid-hashed-secret', + }); + } + return normalized; +} + +function sha256Hex(hexValue: string): string { + const bytes = Buffer.from(normalizeHex(hexValue).slice(2), 'hex'); + return `0x${createHash('sha256').update(bytes).digest('hex')}`; +} + +interface ForwardingServiceConfig { + enabled: boolean; + minFee: string; + timeoutMs: number; + allowPrivateDestinations: boolean; + allowedBaseUrls: string[]; +} + +export class ForwardingService { + private readonly counterpartyService: CounterpartyService; + + private readonly enabledValue: boolean; + + private readonly minFee: bigint; + + private readonly timeoutMs: number; + + private readonly allowPrivateDestinations: boolean; + + private readonly allowedBaseUrls: Set; + + constructor({ + counterpartyService, + config, + }: { + counterpartyService: CounterpartyService; + config: ForwardingServiceConfig; + }) { + this.counterpartyService = counterpartyService; + this.enabledValue = config.enabled; + this.minFee = BigInt(config.minFee); + this.timeoutMs = config.timeoutMs; + this.allowPrivateDestinations = config.allowPrivateDestinations; + this.allowedBaseUrls = new Set(config.allowedBaseUrls.map(normalizeBaseUrl)); + } + + get enabled(): boolean { + return this.enabledValue; + } + + async processTransfer(payload: unknown): Promise { + if (!this.enabledValue) { + throw new ForwardingServiceError(404, 'forwarding is not enabled', { + reason: 'forwarding-disabled', + }); + } + + if (!this.counterpartyService.enabled || !this.counterpartyService.counterpartyPrincipal) { + throw new ForwardingServiceError(503, 'counterparty signing is not configured', { + reason: 'counterparty-signing-disabled', + }); + } + + const request = this.parseRequest(payload); + const incomingAmount = BigInt(request.incomingAmount); + const outgoingAmount = BigInt(request.outgoingAmount); + if (outgoingAmount > incomingAmount) { + throw new ForwardingServiceError(403, 'forwarding fee would be negative', { + reason: 'negative-forwarding-fee', + }); + } + const feeAmount = incomingAmount - outgoingAmount; + if (feeAmount < this.minFee) { + throw new ForwardingServiceError(403, 'forwarding fee below minimum', { + reason: 'forwarding-fee-too-low', + feeAmount: feeAmount.toString(10), + minFee: this.minFee.toString(10), + }); + } + + if ( + this.allowedBaseUrls.size > 0 && + !this.allowedBaseUrls.has(request.nextHopBaseUrl) + ) { + throw new ForwardingServiceError(403, 'next hop base url is not allowed', { + reason: 'next-hop-not-allowed', + }); + } + + const nextHopResponse = await this.requestNextHopSignature(request); + const incomingResult = await this.counterpartyService.signTransfer( + request.incomingPayload, + ); + + return { + paymentId: request.paymentId, + incomingAmount: request.incomingAmount, + outgoingAmount: request.outgoingAmount, + feeAmount: feeAmount.toString(10), + hashedSecret: request.hashedSecret, + nextHopBaseUrl: request.nextHopBaseUrl, + nextHopEndpoint: request.nextHopEndpoint, + upstreamBaseUrl: request.upstreamBaseUrl, + upstreamRevealEndpoint: request.upstreamRevealEndpoint, + upstreamPaymentId: request.upstreamPaymentId, + incomingResult, + nextHopResponse, + }; + } + + private parseRequest(payload: unknown): ForwardTransferRequest { + if (!isRecord(payload)) { + throw new ForwardingServiceError(400, 'payload must be an object', { + reason: 'invalid-payload', + }); + } + + const paymentId = normalizeId(payload.paymentId, 'payment-id'); + const incomingAmount = parseAmount(payload.incomingAmount, 'incomingAmount'); + const outgoingAmount = parseAmount(payload.outgoingAmount, 'outgoingAmount'); + const hashedSecret = normalizeHashedSecret(payload.hashedSecret); + + const incoming = payload.incoming; + if (!isRecord(incoming)) { + throw new ForwardingServiceError(400, 'incoming payload is required', { + reason: 'invalid-incoming-payload', + }); + } + + const outgoing = payload.outgoing; + if (!isRecord(outgoing)) { + throw new ForwardingServiceError(400, 'outgoing payload is required', { + reason: 'invalid-outgoing-payload', + }); + } + + if (!isRecord(outgoing.payload)) { + throw new ForwardingServiceError(400, 'outgoing.payload must be an object', { + reason: 'invalid-outgoing-payload', + }); + } + + const upstream = payload.upstream; + let upstreamBaseUrl: string | null = null; + let upstreamRevealEndpoint: string | null = null; + let upstreamPaymentId: string | null = null; + if (upstream !== undefined && upstream !== null) { + if (!isRecord(upstream)) { + throw new ForwardingServiceError(400, 'upstream must be an object', { + reason: 'invalid-upstream', + }); + } + + if (typeof upstream.baseUrl !== 'string' || upstream.baseUrl.trim() === '') { + throw new ForwardingServiceError(400, 'upstream.baseUrl is required', { + reason: 'invalid-upstream-base-url', + }); + } + upstreamBaseUrl = normalizeBaseUrl(upstream.baseUrl); + upstreamRevealEndpoint = normalizeRevealEndpoint(upstream.revealEndpoint); + upstreamPaymentId = normalizeId( + upstream.paymentId, + 'upstream-payment-id', + ); + } + + const normalizePayload = ( + value: Record, + label: string, + ): Record => { + const out = { ...value }; + const providedHashed = + typeof out.hashedSecret === 'string' && out.hashedSecret.trim() !== '' + ? normalizeHashedSecret(out.hashedSecret) + : null; + const providedSecret = + typeof out.secret === 'string' && out.secret.trim() !== '' + ? normalizeHex(out.secret) + : null; + + if (providedHashed && providedHashed !== hashedSecret) { + throw new ForwardingServiceError(400, `${label}.hashedSecret mismatch`, { + reason: 'hashed-secret-mismatch', + }); + } + + if (providedSecret && providedSecret !== hashedSecret) { + throw new ForwardingServiceError(400, `${label}.secret must equal hashedSecret`, { + reason: 'hashed-secret-mismatch', + }); + } + + out.hashedSecret = hashedSecret; + out.secret = hashedSecret; + validateTransferPayloadShape(out, label); + return out; + }; + + return { + paymentId, + incomingAmount, + outgoingAmount, + hashedSecret, + nextHopBaseUrl: normalizeBaseUrl(String(outgoing.baseUrl || '')), + nextHopEndpoint: normalizeEndpoint(outgoing.endpoint), + nextHopPayload: normalizePayload(outgoing.payload, 'outgoing.payload'), + incomingPayload: normalizePayload(incoming, 'incoming'), + upstreamBaseUrl, + upstreamRevealEndpoint, + upstreamPaymentId, + }; + } + + private async requestNextHopSignature( + request: ForwardTransferRequest, + ): Promise> { + const seed = buildProtocolSeed(request.paymentId); + const url = `${request.nextHopBaseUrl}${request.nextHopEndpoint}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + await enforcePublicDestination( + request.nextHopBaseUrl, + this.allowPrivateDestinations, + 'next-hop', + ); + + const response = await fetch(url, { + method: 'POST', + redirect: 'error', + headers: { + 'content-type': 'application/json', + 'x-stackflow-protocol-version': PEER_PROTOCOL_VERSION, + 'x-stackflow-request-id': `fwd-req-${seed}`.slice(0, MAX_ID_LENGTH), + 'idempotency-key': `fwd-idem-${seed}`.slice(0, MAX_ID_LENGTH), + }, + body: JSON.stringify(request.nextHopPayload), + signal: controller.signal, + }); + + const body = await response.json().catch(() => ({})); + if (!isRecord(body)) { + throw new ForwardingServiceError(502, 'next hop returned invalid body', { + reason: 'next-hop-invalid-body', + statusCode: response.status, + }); + } + + if (!response.ok) { + throw new ForwardingServiceError(502, 'next hop rejected forwarding transfer', { + reason: 'next-hop-rejected', + statusCode: response.status, + }); + } + + if (typeof body.mySignature !== 'string') { + throw new ForwardingServiceError(502, 'next hop did not return mySignature', { + reason: 'next-hop-missing-signature', + statusCode: response.status, + }); + } + + return body; + } catch (error) { + if (error instanceof ForwardingServiceError) { + throw error; + } + if (error instanceof CounterpartyServiceError) { + throw error; + } + if (error instanceof Error && error.name === 'AbortError') { + throw new ForwardingServiceError(504, 'next hop request timed out', { + reason: 'next-hop-timeout', + }); + } + throw new ForwardingServiceError(502, 'failed to reach next hop', { + reason: 'next-hop-unreachable', + details: error instanceof Error ? error.message : String(error), + }); + } finally { + clearTimeout(timer); + } + } + + verifyRevealSecret(args: { + hashedSecret: string; + secret: unknown; + }): { secret: string; hashedSecret: string } { + const hashedSecret = normalizeHashedSecret(args.hashedSecret); + const secret = normalizeHashedSecret(args.secret); + const computed = sha256Hex(secret); + if (computed !== hashedSecret) { + throw new ForwardingServiceError(400, 'secret does not match hashedSecret', { + reason: 'invalid-secret-preimage', + }); + } + return { secret, hashedSecret }; + } + + async propagateRevealToUpstream(args: { + payment: ForwardingPaymentRecord; + secret: string; + attempt: number; + }): Promise> { + const payment = args.payment; + if (!payment.upstreamBaseUrl || !payment.upstreamPaymentId) { + throw new ForwardingServiceError( + 400, + 'upstream payment route is not configured', + { reason: 'upstream-route-missing' }, + ); + } + + const revealEndpoint = payment.upstreamRevealEndpoint || '/forwarding/reveal'; + const secret = normalizeHashedSecret(args.secret); + const url = `${payment.upstreamBaseUrl}${revealEndpoint}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + const stableSeed = normalizeId(payment.paymentId, 'payment-id') + .toLowerCase() + .replace(/[^a-z0-9._:-]/g, '-') + .slice(0, 80); + const idempotencyKey = `reveal-${stableSeed}`.slice(0, MAX_ID_LENGTH); + const requestId = `reveal-${stableSeed}-${Math.max(1, args.attempt)}`.slice( + 0, + MAX_ID_LENGTH, + ); + + try { + await enforcePublicDestination( + payment.upstreamBaseUrl, + this.allowPrivateDestinations, + 'upstream-reveal', + ); + + const response = await fetch(url, { + method: 'POST', + redirect: 'error', + headers: { + 'content-type': 'application/json', + 'x-stackflow-protocol-version': PEER_PROTOCOL_VERSION, + 'x-stackflow-request-id': requestId, + 'idempotency-key': idempotencyKey, + }, + body: JSON.stringify({ + paymentId: payment.upstreamPaymentId, + secret, + }), + signal: controller.signal, + }); + + const body = await response.json().catch(() => ({})); + if (!isRecord(body)) { + throw new ForwardingServiceError(502, 'upstream reveal returned invalid body', { + reason: 'upstream-reveal-invalid-body', + statusCode: response.status, + }); + } + + if (!response.ok) { + throw new ForwardingServiceError(502, 'upstream reveal rejected', { + reason: 'upstream-reveal-rejected', + statusCode: response.status, + }); + } + + return body; + } catch (error) { + if (error instanceof ForwardingServiceError) { + throw error; + } + if (error instanceof Error && error.name === 'AbortError') { + throw new ForwardingServiceError(504, 'upstream reveal request timed out', { + reason: 'upstream-reveal-timeout', + }); + } + throw new ForwardingServiceError(502, 'failed to reach upstream reveal endpoint', { + reason: 'upstream-reveal-unreachable', + details: error instanceof Error ? error.message : String(error), + }); + } finally { + clearTimeout(timer); + } + } +} + +export class ForwardingServiceError extends Error { + readonly statusCode: number; + + readonly details: Record | null; + + constructor( + statusCode: number, + message: string, + details: Record | null = null, + ) { + super(message); + this.name = 'ForwardingServiceError'; + this.statusCode = statusCode; + this.details = details; + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 2f346f4..fcedf96 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,8 +1,10 @@ import 'dotenv/config'; +import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import http from 'node:http'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import net from 'node:net'; import path from 'node:path'; import { loadConfig } from './config.js'; @@ -20,11 +22,18 @@ import { createCounterpartySigner, CounterpartyService, CounterpartyServiceError, + type CounterpartySignRequest, } from './counterparty-service.js'; +import { + ForwardingService, + ForwardingServiceError, +} from './forwarding-service.js'; import { SqliteStateStore } from './state-store.js'; import { canonicalPipeKey } from './principal-utils.js'; +import { normalizePipeId } from './observer-parser.js'; import type { DisputeExecutor, + ForwardingPaymentRecord, PipeKey, SignatureVerifier, StackflowNodeStatus, @@ -44,6 +53,14 @@ const STACKS_NODE_COMPAT_ROUTES = new Set([ ]); const DEFAULT_STACKFLOW_CONTRACT_PATTERN = /\.stackflow(?:[-.].+)?$/i; const RAW_EVENT_LOG_MAX_CHARS = 25_000; +const PEER_PROTOCOL_VERSION = '1'; +const HEADER_PEER_PROTOCOL_VERSION = 'x-stackflow-protocol-version'; +const HEADER_PEER_REQUEST_ID = 'x-stackflow-request-id'; +const HEADER_IDEMPOTENCY_KEY = 'idempotency-key'; +const MAX_PROTOCOL_ID_LENGTH = 128; +const PROTOCOL_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/; +const WRITE_RATE_LIMIT_WINDOW_MS = 60_000; +const FORWARDING_REVEAL_RETRY_BATCH_SIZE = 25; const UI_FILE_MAP: Record = { '/app': { @@ -72,9 +89,11 @@ function writeJson( response: ServerResponse, statusCode: number, payload: Record, + extraHeaders: Record = {}, ): void { response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', + ...extraHeaders, }); response.end(JSON.stringify(payload)); } @@ -83,6 +102,485 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +interface PeerRequestMetadata { + protocolVersion: string; + requestId: string; + idempotencyKey: string; +} + +interface WriteRateLimitCounter { + windowStartedAtMs: number; + count: number; +} + +interface ObserverIngressPolicy { + localhostOnly: boolean; + allowedIps: Set; +} + +interface SensitiveReadPolicy { + adminToken: string | null; + localhostOnlyWithoutToken: boolean; + redactWithoutToken: boolean; + trustProxy: boolean; +} + +interface SensitiveReadAccess { + allowed: boolean; + fullAccess: boolean; + sourceIp: string | null; + statusCode: number; + reason: string; +} + +class PeerProtocolError extends Error { + readonly statusCode: number; + + readonly reason: string; + + constructor(statusCode: number, reason: string, message: string) { + super(message); + this.statusCode = statusCode; + this.reason = reason; + } +} + +function readSingleHeader( + request: IncomingMessage, + headerName: string, +): string | null { + const raw = request.headers[headerName]; + if (Array.isArray(raw)) { + return raw.length > 0 ? raw[0] : null; + } + if (typeof raw === 'string') { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; + } + return null; +} + +function validateProtocolId(value: string): boolean { + if (value.length < 8 || value.length > MAX_PROTOCOL_ID_LENGTH) { + return false; + } + return PROTOCOL_ID_PATTERN.test(value); +} + +function parsePeerRequestMetadata(request: IncomingMessage): PeerRequestMetadata { + const protocolVersion = readSingleHeader(request, HEADER_PEER_PROTOCOL_VERSION); + if (!protocolVersion) { + throw new PeerProtocolError( + 400, + 'missing-protocol-version', + `${HEADER_PEER_PROTOCOL_VERSION} header is required`, + ); + } + if (protocolVersion !== PEER_PROTOCOL_VERSION) { + throw new PeerProtocolError( + 400, + 'unsupported-protocol-version', + `unsupported protocol version: ${protocolVersion}`, + ); + } + + const requestId = readSingleHeader(request, HEADER_PEER_REQUEST_ID); + if (!requestId) { + throw new PeerProtocolError( + 400, + 'missing-request-id', + `${HEADER_PEER_REQUEST_ID} header is required`, + ); + } + if (!validateProtocolId(requestId)) { + throw new PeerProtocolError( + 400, + 'invalid-request-id', + `${HEADER_PEER_REQUEST_ID} must be 8-128 chars [a-zA-Z0-9._:-]`, + ); + } + + const idempotencyKey = readSingleHeader(request, HEADER_IDEMPOTENCY_KEY); + if (!idempotencyKey) { + throw new PeerProtocolError( + 400, + 'missing-idempotency-key', + `${HEADER_IDEMPOTENCY_KEY} header is required`, + ); + } + if (!validateProtocolId(idempotencyKey)) { + throw new PeerProtocolError( + 400, + 'invalid-idempotency-key', + `${HEADER_IDEMPOTENCY_KEY} must be 8-128 chars [a-zA-Z0-9._:-]`, + ); + } + + return { + protocolVersion, + requestId, + idempotencyKey, + }; +} + +function hashRequestPayload(payload: unknown): string { + return createHash('sha256') + .update(JSON.stringify(payload)) + .digest('hex'); +} + +function withPeerProtocolMeta( + payload: Record, + metadata: PeerRequestMetadata, +): Record { + return { + ...payload, + protocolVersion: metadata.protocolVersion, + requestId: metadata.requestId, + idempotencyKey: metadata.idempotencyKey, + processedAt: new Date().toISOString(), + }; +} + +function normalizeIpAddress(value: string): string | null { + let text = value.trim(); + if (!text) { + return null; + } + + if (text.startsWith('[') && text.endsWith(']')) { + text = text.slice(1, -1); + } + + const zoneSeparator = text.indexOf('%'); + if (zoneSeparator >= 0) { + text = text.slice(0, zoneSeparator); + } + + const mappedV4Match = text.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (mappedV4Match) { + const upper = Number.parseInt(mappedV4Match[1], 16); + const lower = Number.parseInt(mappedV4Match[2], 16); + return `${(upper >> 8) & 255}.${upper & 255}.${(lower >> 8) & 255}.${lower & 255}`; + } + + if (text.toLowerCase().startsWith('::ffff:')) { + const candidate = text.slice('::ffff:'.length); + if (net.isIP(candidate) === 4) { + return candidate; + } + } + + const ipVersion = net.isIP(text); + if (ipVersion === 4) { + return text; + } + + if (ipVersion === 6) { + try { + const hostname = new URL(`http://[${text}]/`).hostname; + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return hostname.slice(1, -1).toLowerCase(); + } + } catch { + return text.toLowerCase(); + } + return text.toLowerCase(); + } + + return null; +} + +function getRemoteIp(request: IncomingMessage): string | null { + const remote = request.socket.remoteAddress; + if (!remote) { + return null; + } + return normalizeIpAddress(remote); +} + +function isLoopbackIp(ip: string): boolean { + if (ip === '::1') { + return true; + } + return net.isIP(ip) === 4 && ip.startsWith('127.'); +} + +function isPublicBindHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + if (!normalized) { + return false; + } + + if (normalized === 'localhost') { + return false; + } + + if (normalized === '0.0.0.0' || normalized === '::' || normalized === '[::]') { + return true; + } + + const asIp = normalizeIpAddress(normalized); + if (asIp) { + return !isLoopbackIp(asIp); + } + + // Non-localhost hostnames are treated as public bind targets. + return true; +} + +function buildObserverIngressPolicy(args: { + observerLocalhostOnly: boolean; + observerAllowedIps: string[]; +}): ObserverIngressPolicy { + const allowedIps = new Set(); + for (const candidate of args.observerAllowedIps) { + const normalized = normalizeIpAddress(candidate); + if (!normalized) { + throw new Error( + `STACKFLOW_NODE_OBSERVER_ALLOWED_IPS contains invalid IP: ${candidate}`, + ); + } + allowedIps.add(normalized); + } + + return { + localhostOnly: args.observerLocalhostOnly, + allowedIps, + }; +} + +function isObserverSourceAllowed( + request: IncomingMessage, + policy: ObserverIngressPolicy, +): { allowed: boolean; sourceIp: string | null } { + const sourceIp = getRemoteIp(request); + if (!sourceIp) { + return { allowed: false, sourceIp: null }; + } + + if (policy.allowedIps.size > 0) { + return { + allowed: policy.allowedIps.has(sourceIp), + sourceIp, + }; + } + + if (policy.localhostOnly) { + return { + allowed: isLoopbackIp(sourceIp), + sourceIp, + }; + } + + return { allowed: true, sourceIp }; +} + +function normalizeForwardedIpCandidate(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const bracketed = trimmed.match(/^\[([^[\]]+)\](?::\d+)?$/); + if (bracketed) { + return normalizeIpAddress(bracketed[1]); + } + + const v4WithPort = trimmed.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/); + if (v4WithPort) { + return normalizeIpAddress(v4WithPort[1]); + } + + return normalizeIpAddress(trimmed); +} + +function extractClientIp( + request: IncomingMessage, + trustProxy: boolean, +): string | null { + if (trustProxy) { + const forwarded = readSingleHeader(request, 'x-forwarded-for'); + if (forwarded) { + const firstHop = forwarded.split(',')[0] ?? ''; + const normalized = normalizeForwardedIpCandidate(firstHop); + if (normalized) { + return normalized; + } + } + } + + return getRemoteIp(request); +} + +function extractAdminToken(request: IncomingMessage): string | null { + const authorization = readSingleHeader(request, 'authorization'); + if (authorization && authorization.toLowerCase().startsWith('bearer ')) { + const token = authorization.slice('bearer '.length).trim(); + if (token) { + return token; + } + } + + const fallback = readSingleHeader(request, 'x-stackflow-admin-token'); + return fallback && fallback.trim() ? fallback.trim() : null; +} + +function getSensitiveReadAccess( + request: IncomingMessage, + policy: SensitiveReadPolicy, +): SensitiveReadAccess { + const sourceIp = extractClientIp(request, policy.trustProxy); + const adminToken = extractAdminToken(request); + + if (policy.adminToken) { + if (adminToken === policy.adminToken) { + return { + allowed: true, + fullAccess: true, + sourceIp, + statusCode: 200, + reason: 'admin-token', + }; + } + + return { + allowed: false, + fullAccess: false, + sourceIp, + statusCode: 401, + reason: 'invalid-admin-read-token', + }; + } + + if (policy.localhostOnlyWithoutToken) { + if (!sourceIp || !isLoopbackIp(sourceIp)) { + return { + allowed: false, + fullAccess: false, + sourceIp, + statusCode: 403, + reason: 'sensitive-read-localhost-only', + }; + } + } + + return { + allowed: true, + fullAccess: false, + sourceIp, + statusCode: 200, + reason: 'redacted', + }; +} + +function redactSignatureState( + value: Record, +): Record { + return { + ...value, + mySignature: + typeof value.mySignature === 'string' ? '[redacted]' : value.mySignature, + theirSignature: + typeof value.theirSignature === 'string' + ? '[redacted]' + : value.theirSignature, + secret: null, + }; +} + +function redactSensitiveObject(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveObject(item)); + } + + if (!isRecord(value)) { + return value; + } + + const out: Record = {}; + for (const [key, fieldValue] of Object.entries(value)) { + if ( + key === 'mySignature' || + key === 'theirSignature' || + key === 'counterpartySignature' + ) { + out[key] = typeof fieldValue === 'string' ? '[redacted]' : fieldValue; + continue; + } + if (key === 'secret' || key === 'revealedSecret') { + out[key] = null; + continue; + } + out[key] = redactSensitiveObject(fieldValue); + } + return out; +} + +function redactForwardingPayment( + payment: ForwardingPaymentRecord | null, +): ForwardingPaymentRecord | null { + if (!payment) { + return null; + } + + return { + ...payment, + revealedSecret: null, + resultJson: (redactSensitiveObject(payment.resultJson) || + {}) as Record, + }; +} + +function extractPaymentId(payload: unknown): string | null { + if (!isRecord(payload) || typeof payload.paymentId !== 'string') { + return null; + } + const value = payload.paymentId.trim(); + return value.length > 0 ? value : null; +} + +function extractForwardingHashedSecret(payload: unknown): string | null { + if (!isRecord(payload) || typeof payload.hashedSecret !== 'string') { + return null; + } + const value = payload.hashedSecret.trim().toLowerCase(); + return value.length > 0 ? value : null; +} + +interface ForwardingPipeMetadata { + contractId: string | null; + pipeId: string | null; + pipeNonce: string | null; +} + +function deriveForwardingPipeMetadata( + request: Pick< + CounterpartySignRequest, + 'contractId' | 'forPrincipal' | 'withPrincipal' | 'token' | 'nonce' + >, +): ForwardingPipeMetadata { + const pipeKey = canonicalPipeKey( + request.token, + request.forPrincipal, + request.withPrincipal, + ); + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + return { + contractId: request.contractId, + pipeId: null, + pipeNonce: request.nonce, + }; + } + + return { + contractId: request.contractId, + pipeId, + pipeNonce: request.nonce, + }; +} + function summarizeNewBlockPayload(payload: unknown): string { if (!isRecord(payload)) { return 'payload=non-object'; @@ -472,9 +970,115 @@ async function maybeServeUi( return true; } +function formatErrorMessage(error: unknown): string { + if (error instanceof ForwardingServiceError) { + const reason = + error.details && typeof error.details.reason === 'string' + ? error.details.reason + : null; + return reason ? `${error.message} (${reason})` : error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function shouldPropagateReveal(payment: ForwardingPaymentRecord): boolean { + return Boolean(payment.upstreamBaseUrl && payment.upstreamPaymentId); +} + +function nextRetryAt(retryIntervalMs: number): string { + return new Date(Date.now() + retryIntervalMs).toISOString(); +} + +async function propagateRevealForPayment({ + payment, + secret, + trigger, + forwardingService, + stateStore, + retryIntervalMs, + maxAttempts, +}: { + payment: ForwardingPaymentRecord; + secret: string; + trigger: 'api' | 'retry'; + forwardingService: ForwardingService; + stateStore: SqliteStateStore; + retryIntervalMs: number; + maxAttempts: number; +}): Promise { + if (!shouldPropagateReveal(payment)) { + const updated = { + ...payment, + revealPropagationStatus: 'not-applicable' as const, + revealLastError: null, + revealNextRetryAt: null, + revealPropagatedAt: null, + updatedAt: new Date().toISOString(), + }; + stateStore.setForwardingPayment(updated); + return updated; + } + + if (payment.revealPropagationStatus === 'propagated') { + return payment; + } + + const attempt = payment.revealPropagationAttempts + 1; + const attemptPrefix = `[stackflow-node] reveal propagation trigger=${trigger} paymentId=${payment.paymentId} attempt=${attempt}`; + console.log( + `${attemptPrefix} upstream=${payment.upstreamBaseUrl}${payment.upstreamRevealEndpoint || '/forwarding/reveal'} upstreamPaymentId=${payment.upstreamPaymentId}`, + ); + + try { + await forwardingService.propagateRevealToUpstream({ + payment, + secret, + attempt, + }); + + const updatedAt = new Date().toISOString(); + const updated = { + ...payment, + revealPropagationStatus: 'propagated' as const, + revealPropagationAttempts: attempt, + revealLastError: null, + revealNextRetryAt: null, + revealPropagatedAt: updatedAt, + updatedAt, + }; + stateStore.setForwardingPayment(updated); + console.log(`${attemptPrefix} result=propagated`); + return updated; + } catch (error) { + const errorText = formatErrorMessage(error); + const reachedLimit = attempt >= maxAttempts; + const updatedAt = new Date().toISOString(); + const updated = { + ...payment, + revealPropagationStatus: reachedLimit ? ('failed' as const) : ('pending' as const), + revealPropagationAttempts: attempt, + revealLastError: errorText, + revealNextRetryAt: reachedLimit ? null : nextRetryAt(retryIntervalMs), + revealPropagatedAt: null, + updatedAt, + }; + stateStore.setForwardingPayment(updated); + console.warn( + `${attemptPrefix} result=${updated.revealPropagationStatus} error=${errorText} nextRetryAt=${updated.revealNextRetryAt ?? '-'}`, + ); + return updated; + } +} + function createHandler({ stackflowNode, + stateStore, counterpartyService, + forwardingService, + propagateReveal, startedAt, disputeEnabled, signerAddress, @@ -483,9 +1087,21 @@ function createHandler({ stacksNetwork, watchedContracts, logRawEvents, + peerWriteRateLimitPerMinute, + trustProxy, + observerIngressPolicy, + sensitiveReadPolicy, + forwardingAllowPrivateDestinations, }: { stackflowNode: StackflowNode; + stateStore: SqliteStateStore; counterpartyService: CounterpartyService; + forwardingService: ForwardingService; + propagateReveal: (args: { + payment: ForwardingPaymentRecord; + secret: string; + trigger: 'api' | 'retry'; + }) => Promise; startedAt: string; disputeEnabled: boolean; signerAddress: string | null; @@ -494,7 +1110,52 @@ function createHandler({ stacksNetwork: 'mainnet' | 'testnet' | 'devnet' | 'mocknet'; watchedContracts: string[]; logRawEvents: boolean; + peerWriteRateLimitPerMinute: number; + trustProxy: boolean; + observerIngressPolicy: ObserverIngressPolicy; + sensitiveReadPolicy: SensitiveReadPolicy; + forwardingAllowPrivateDestinations: boolean; }) { + const writeRateLimitCounters = new Map(); + + const consumeWriteRateLimit = ( + request: IncomingMessage, + ): { limited: false } | { limited: true; retryAfterSeconds: number } => { + if (peerWriteRateLimitPerMinute <= 0) { + return { limited: false }; + } + + const now = Date.now(); + if (writeRateLimitCounters.size > 10_000) { + for (const [key, value] of writeRateLimitCounters.entries()) { + if (now - value.windowStartedAtMs >= WRITE_RATE_LIMIT_WINDOW_MS) { + writeRateLimitCounters.delete(key); + } + } + } + const clientIp = extractClientIp(request, trustProxy) ?? 'unknown'; + const existing = writeRateLimitCounters.get(clientIp); + if (!existing || now - existing.windowStartedAtMs >= WRITE_RATE_LIMIT_WINDOW_MS) { + writeRateLimitCounters.set(clientIp, { + windowStartedAtMs: now, + count: 1, + }); + return { limited: false }; + } + + if (existing.count >= peerWriteRateLimitPerMinute) { + const elapsed = now - existing.windowStartedAtMs; + const retryAfterMs = Math.max(1_000, WRITE_RATE_LIMIT_WINDOW_MS - elapsed); + return { + limited: true, + retryAfterSeconds: Math.ceil(retryAfterMs / 1_000), + }; + } + + existing.count += 1; + return { limited: false }; + }; + return async ( request: IncomingMessage, response: ServerResponse, @@ -523,7 +1184,16 @@ function createHandler({ signerAddress, counterpartyEnabled, counterpartyPrincipal, + forwardingEnabled: forwardingService.enabled, stacksNetwork, + peerWriteRateLimitPerMinute, + trustProxy, + observerLocalhostOnly: observerIngressPolicy.localhostOnly, + observerAllowedIps: [...observerIngressPolicy.allowedIps], + adminReadTokenConfigured: Boolean(sensitiveReadPolicy.adminToken), + adminReadLocalhostOnly: sensitiveReadPolicy.localhostOnlyWithoutToken, + redactSensitiveReadData: sensitiveReadPolicy.redactWithoutToken, + forwardingAllowPrivateDestinations, }); return; } @@ -538,12 +1208,36 @@ function createHandler({ } if (method === 'GET' && url.pathname === '/signature-states') { + const access = getSensitiveReadAccess(request, sensitiveReadPolicy); + if (!access.allowed) { + writeJson( + response, + access.statusCode, + { + ok: false, + error: 'sensitive read not authorized', + reason: access.reason, + }, + access.statusCode === 401 + ? { 'www-authenticate': 'Bearer realm="stackflow-node-admin-read"' } + : {}, + ); + return; + } + const status = stackflowNode.status(); const limit = parseLimit(url); + const signatureStates = status.signatureStates.slice(0, limit); + const shouldRedact = sensitiveReadPolicy.redactWithoutToken && !access.fullAccess; writeJson(response, 200, { ok: true, - signatureStates: status.signatureStates.slice(0, limit), + redacted: shouldRedact, + signatureStates: shouldRedact + ? signatureStates.map((state) => + redactSignatureState(state as unknown as Record), + ) + : signatureStates, }); return; } @@ -582,7 +1276,65 @@ function createHandler({ return; } + if (method === 'GET' && url.pathname === '/forwarding/payments') { + const access = getSensitiveReadAccess(request, sensitiveReadPolicy); + if (!access.allowed) { + writeJson( + response, + access.statusCode, + { + ok: false, + error: 'sensitive read not authorized', + reason: access.reason, + }, + access.statusCode === 401 + ? { 'www-authenticate': 'Bearer realm="stackflow-node-admin-read"' } + : {}, + ); + return; + } + + const shouldRedact = sensitiveReadPolicy.redactWithoutToken && !access.fullAccess; + const limit = parseLimit(url); + const paymentId = url.searchParams.get('paymentId')?.trim(); + if (paymentId) { + const payment = stateStore.getForwardingPayment(paymentId); + writeJson(response, 200, { + ok: true, + redacted: shouldRedact, + payment: shouldRedact ? redactForwardingPayment(payment) : payment, + }); + return; + } + + const payments = stateStore.listForwardingPayments(limit); + writeJson(response, 200, { + ok: true, + redacted: shouldRedact, + payments: shouldRedact + ? payments.map((payment) => redactForwardingPayment(payment)) + : payments, + }); + return; + } + if (method === 'POST' && url.pathname === '/signature-states') { + const rateLimit = consumeWriteRateLimit(request); + if (rateLimit.limited) { + writeJson( + response, + 429, + { + ok: false, + error: 'write rate limit exceeded', + reason: 'rate-limit-exceeded', + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + { 'retry-after': String(rateLimit.retryAfterSeconds) }, + ); + return; + } + try { const payload = await readJsonBody(request); const result = await stackflowNode.upsertSignatureState(payload); @@ -656,42 +1408,137 @@ function createHandler({ } if (method === 'POST' && url.pathname === '/counterparty/transfer') { + let peerMetadata: PeerRequestMetadata; try { - const payload = await readJsonBody(request); - const result = await counterpartyService.signTransfer(payload); - - if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { - console.warn( - `[stackflow-node] /counterparty/transfer rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, - ); - writeJson(response, 409, { + peerMetadata = parsePeerRequestMetadata(request); + } catch (error) { + if (error instanceof PeerProtocolError) { + writeJson(response, error.statusCode, { ok: false, - error: 'nonce-too-low', - reason: 'nonce-too-low', - incomingNonce: result.request.nonce, - existingNonce: result.upsert.state.nonce, - state: result.upsert.state, + error: error.message, + reason: error.reason, }); return; } - - writeJson(response, 200, { - ok: true, - counterpartyPrincipal: result.request.forPrincipal, - withPrincipal: result.request.withPrincipal, - token: result.request.token, - amount: result.request.amount, - nonce: result.request.nonce, - action: result.request.action, - actor: result.request.actor, - myBalance: result.request.myBalance, - theirBalance: result.request.theirBalance, - mySignature: result.mySignature, - theirSignature: result.request.theirSignature, - stored: result.upsert.stored, - replaced: result.upsert.replaced, - reason: result.upsert.reason, + writeJson(response, 400, { + ok: false, + error: 'invalid peer protocol headers', + reason: 'invalid-peer-protocol', }); + return; + } + + try { + const payload = await readJsonBody(request); + const requestHash = hashRequestPayload(payload); + const existing = stateStore.getIdempotentResponse( + url.pathname, + peerMetadata.idempotencyKey, + ); + + if (existing) { + if (existing.requestHash !== requestHash) { + writeJson( + response, + 409, + withPeerProtocolMeta( + { + ok: false, + error: 'idempotency key reuse with different payload', + reason: 'idempotency-key-reused', + }, + peerMetadata, + ), + ); + return; + } + + writeJson( + response, + existing.statusCode, + existing.responseJson, + { 'x-stackflow-idempotency-replay': 'true' }, + ); + return; + } + + const rateLimit = consumeWriteRateLimit(request); + if (rateLimit.limited) { + writeJson( + response, + 429, + withPeerProtocolMeta( + { + ok: false, + error: 'write rate limit exceeded', + reason: 'rate-limit-exceeded', + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + peerMetadata, + ), + { 'retry-after': String(rateLimit.retryAfterSeconds) }, + ); + return; + } + + const result = await counterpartyService.signTransfer(payload); + + if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { + console.warn( + `[stackflow-node] /counterparty/transfer rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, + ); + const body = withPeerProtocolMeta( + { + ok: false, + error: 'nonce-too-low', + reason: 'nonce-too-low', + incomingNonce: result.request.nonce, + existingNonce: result.upsert.state.nonce, + state: result.upsert.state, + }, + peerMetadata, + ); + stateStore.setIdempotentResponse({ + endpoint: url.pathname, + idempotencyKey: peerMetadata.idempotencyKey, + requestHash, + statusCode: 409, + responseJson: body, + createdAt: new Date().toISOString(), + }); + writeJson(response, 409, body); + return; + } + + const body = withPeerProtocolMeta( + { + ok: true, + counterpartyPrincipal: result.request.forPrincipal, + withPrincipal: result.request.withPrincipal, + token: result.request.token, + amount: result.request.amount, + nonce: result.request.nonce, + action: result.request.action, + actor: result.request.actor, + myBalance: result.request.myBalance, + theirBalance: result.request.theirBalance, + mySignature: result.mySignature, + theirSignature: result.request.theirSignature, + stored: result.upsert.stored, + replaced: result.upsert.replaced, + reason: result.upsert.reason, + }, + peerMetadata, + ); + stateStore.setIdempotentResponse({ + endpoint: url.pathname, + idempotencyKey: peerMetadata.idempotencyKey, + requestHash, + statusCode: 200, + responseJson: body, + createdAt: new Date().toISOString(), + }); + writeJson(response, 200, body); } catch (error) { if (error instanceof CounterpartyServiceError) { console.warn( @@ -701,11 +1548,15 @@ function createHandler({ error.details && typeof error.details === 'object' ? error.details : {}; - writeJson(response, error.statusCode, { - ok: false, - error: error.message, - ...details, - }); + const body = withPeerProtocolMeta( + { + ok: false, + error: error.message, + ...details, + }, + peerMetadata, + ); + writeJson(response, error.statusCode, body); return; } @@ -724,42 +1575,137 @@ function createHandler({ } if (method === 'POST' && url.pathname === '/counterparty/signature-request') { + let peerMetadata: PeerRequestMetadata; + try { + peerMetadata = parsePeerRequestMetadata(request); + } catch (error) { + if (error instanceof PeerProtocolError) { + writeJson(response, error.statusCode, { + ok: false, + error: error.message, + reason: error.reason, + }); + return; + } + writeJson(response, 400, { + ok: false, + error: 'invalid peer protocol headers', + reason: 'invalid-peer-protocol', + }); + return; + } + try { const payload = await readJsonBody(request); + const requestHash = hashRequestPayload(payload); + const existing = stateStore.getIdempotentResponse( + url.pathname, + peerMetadata.idempotencyKey, + ); + + if (existing) { + if (existing.requestHash !== requestHash) { + writeJson( + response, + 409, + withPeerProtocolMeta( + { + ok: false, + error: 'idempotency key reuse with different payload', + reason: 'idempotency-key-reused', + }, + peerMetadata, + ), + ); + return; + } + + writeJson( + response, + existing.statusCode, + existing.responseJson, + { 'x-stackflow-idempotency-replay': 'true' }, + ); + return; + } + + const rateLimit = consumeWriteRateLimit(request); + if (rateLimit.limited) { + writeJson( + response, + 429, + withPeerProtocolMeta( + { + ok: false, + error: 'write rate limit exceeded', + reason: 'rate-limit-exceeded', + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + peerMetadata, + ), + { 'retry-after': String(rateLimit.retryAfterSeconds) }, + ); + return; + } + const result = await counterpartyService.signSignatureRequest(payload); if (!result.upsert.stored && result.upsert.reason === 'nonce-too-low') { console.warn( `[stackflow-node] /counterparty/signature-request rejected status=409 reason=nonce-too-low incomingNonce=${result.request.nonce} existingNonce=${result.upsert.state.nonce} stateId=${result.upsert.state.stateId}`, ); - writeJson(response, 409, { - ok: false, - error: 'nonce-too-low', - reason: 'nonce-too-low', - incomingNonce: result.request.nonce, - existingNonce: result.upsert.state.nonce, - state: result.upsert.state, + const body = withPeerProtocolMeta( + { + ok: false, + error: 'nonce-too-low', + reason: 'nonce-too-low', + incomingNonce: result.request.nonce, + existingNonce: result.upsert.state.nonce, + state: result.upsert.state, + }, + peerMetadata, + ); + stateStore.setIdempotentResponse({ + endpoint: url.pathname, + idempotencyKey: peerMetadata.idempotencyKey, + requestHash, + statusCode: 409, + responseJson: body, + createdAt: new Date().toISOString(), }); + writeJson(response, 409, body); return; } - writeJson(response, 200, { - ok: true, - counterpartyPrincipal: result.request.forPrincipal, - withPrincipal: result.request.withPrincipal, - token: result.request.token, - amount: result.request.amount, - nonce: result.request.nonce, - action: result.request.action, - actor: result.request.actor, - myBalance: result.request.myBalance, - theirBalance: result.request.theirBalance, - mySignature: result.mySignature, - theirSignature: result.request.theirSignature, - stored: result.upsert.stored, - replaced: result.upsert.replaced, - reason: result.upsert.reason, + const body = withPeerProtocolMeta( + { + ok: true, + counterpartyPrincipal: result.request.forPrincipal, + withPrincipal: result.request.withPrincipal, + token: result.request.token, + amount: result.request.amount, + nonce: result.request.nonce, + action: result.request.action, + actor: result.request.actor, + myBalance: result.request.myBalance, + theirBalance: result.request.theirBalance, + mySignature: result.mySignature, + theirSignature: result.request.theirSignature, + stored: result.upsert.stored, + replaced: result.upsert.replaced, + reason: result.upsert.reason, + }, + peerMetadata, + ); + stateStore.setIdempotentResponse({ + endpoint: url.pathname, + idempotencyKey: peerMetadata.idempotencyKey, + requestHash, + statusCode: 200, + responseJson: body, + createdAt: new Date().toISOString(), }); + writeJson(response, 200, body); } catch (error) { if (error instanceof CounterpartyServiceError) { console.warn( @@ -769,29 +1715,527 @@ function createHandler({ error.details && typeof error.details === 'object' ? error.details : {}; + const body = withPeerProtocolMeta( + { + ok: false, + error: error.message, + ...details, + }, + peerMetadata, + ); + writeJson(response, error.statusCode, body); + return; + } + + console.error( + `[stackflow-node] /counterparty/signature-request error: ${ + error instanceof Error ? error.message : 'failed to sign request' + }`, + ); + writeJson(response, 500, { + ok: false, + error: + error instanceof Error ? error.message : 'failed to sign request', + }); + } + return; + } + + if (method === 'POST' && url.pathname === '/forwarding/transfer') { + let peerMetadata: PeerRequestMetadata; + try { + peerMetadata = parsePeerRequestMetadata(request); + } catch (error) { + if (error instanceof PeerProtocolError) { writeJson(response, error.statusCode, { ok: false, error: error.message, - ...details, + reason: error.reason, }); return; } + writeJson(response, 400, { + ok: false, + error: 'invalid peer protocol headers', + reason: 'invalid-peer-protocol', + }); + return; + } + + let payload: unknown = null; + try { + payload = await readJsonBody(request); + const requestHash = hashRequestPayload(payload); + const existing = stateStore.getIdempotentResponse( + url.pathname, + peerMetadata.idempotencyKey, + ); + + if (existing) { + if (existing.requestHash !== requestHash) { + writeJson( + response, + 409, + withPeerProtocolMeta( + { + ok: false, + error: 'idempotency key reuse with different payload', + reason: 'idempotency-key-reused', + }, + peerMetadata, + ), + ); + return; + } + + writeJson( + response, + existing.statusCode, + existing.responseJson, + { 'x-stackflow-idempotency-replay': 'true' }, + ); + return; + } + + const rateLimit = consumeWriteRateLimit(request); + if (rateLimit.limited) { + writeJson( + response, + 429, + withPeerProtocolMeta( + { + ok: false, + error: 'write rate limit exceeded', + reason: 'rate-limit-exceeded', + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + peerMetadata, + ), + { 'retry-after': String(rateLimit.retryAfterSeconds) }, + ); + return; + } + + const result = await forwardingService.processTransfer(payload); + const responseBody = withPeerProtocolMeta( + { + ok: true, + paymentId: result.paymentId, + incomingAmount: result.incomingAmount, + outgoingAmount: result.outgoingAmount, + feeAmount: result.feeAmount, + hashedSecret: result.hashedSecret, + nextHopBaseUrl: result.nextHopBaseUrl, + nextHopEndpoint: result.nextHopEndpoint, + revealUpstream: + result.upstreamBaseUrl && result.upstreamPaymentId + ? { + baseUrl: result.upstreamBaseUrl, + revealEndpoint: result.upstreamRevealEndpoint, + paymentId: result.upstreamPaymentId, + } + : null, + upstream: { + counterpartyPrincipal: result.incomingResult.request.forPrincipal, + withPrincipal: result.incomingResult.request.withPrincipal, + nonce: result.incomingResult.request.nonce, + mySignature: result.incomingResult.mySignature, + theirSignature: result.incomingResult.request.theirSignature, + stored: result.incomingResult.upsert.stored, + replaced: result.incomingResult.upsert.replaced, + }, + downstream: result.nextHopResponse, + }, + peerMetadata, + ); + + const now = new Date().toISOString(); + const pipeMetadata = deriveForwardingPipeMetadata(result.incomingResult.request); + stateStore.setForwardingPayment({ + paymentId: result.paymentId, + contractId: pipeMetadata.contractId, + pipeId: pipeMetadata.pipeId, + pipeNonce: pipeMetadata.pipeNonce, + status: 'completed', + incomingAmount: result.incomingAmount, + outgoingAmount: result.outgoingAmount, + feeAmount: result.feeAmount, + hashedSecret: result.hashedSecret, + revealedSecret: null, + revealedAt: null, + upstreamBaseUrl: result.upstreamBaseUrl, + upstreamRevealEndpoint: result.upstreamRevealEndpoint, + upstreamPaymentId: result.upstreamPaymentId, + revealPropagationStatus: + result.upstreamBaseUrl && result.upstreamPaymentId + ? 'pending' + : 'not-applicable', + revealPropagationAttempts: 0, + revealLastError: null, + revealNextRetryAt: null, + revealPropagatedAt: null, + nextHopBaseUrl: result.nextHopBaseUrl, + nextHopEndpoint: result.nextHopEndpoint, + resultJson: responseBody, + error: null, + createdAt: now, + updatedAt: now, + }); + + stateStore.setIdempotentResponse({ + endpoint: url.pathname, + idempotencyKey: peerMetadata.idempotencyKey, + requestHash, + statusCode: 200, + responseJson: responseBody, + createdAt: now, + }); + writeJson(response, 200, responseBody); + } catch (error) { + if (error instanceof ForwardingServiceError) { + console.warn( + `[stackflow-node] /forwarding/transfer rejected status=${error.statusCode} error=${error.message}`, + ); + const details = + error.details && typeof error.details === 'object' + ? error.details + : {}; + const body = withPeerProtocolMeta( + { + ok: false, + error: error.message, + ...details, + }, + peerMetadata, + ); + + const paymentId = extractPaymentId(payload); + if (paymentId) { + const now = new Date().toISOString(); + const incomingAmount = + isRecord(payload) && typeof payload.incomingAmount !== 'undefined' + ? String(payload.incomingAmount) + : '0'; + const outgoingAmount = + isRecord(payload) && typeof payload.outgoingAmount !== 'undefined' + ? String(payload.outgoingAmount) + : '0'; + const feeAmount = + /^\d+$/.test(incomingAmount) && /^\d+$/.test(outgoingAmount) + ? (BigInt(incomingAmount) - BigInt(outgoingAmount)).toString(10) + : '0'; + const outgoing = isRecord(payload) && isRecord(payload.outgoing) + ? payload.outgoing + : null; + stateStore.setForwardingPayment({ + paymentId, + contractId: null, + pipeId: null, + pipeNonce: null, + status: 'failed', + incomingAmount, + outgoingAmount, + feeAmount, + hashedSecret: extractForwardingHashedSecret(payload), + revealedSecret: null, + revealedAt: null, + upstreamBaseUrl: null, + upstreamRevealEndpoint: null, + upstreamPaymentId: null, + revealPropagationStatus: 'not-applicable', + revealPropagationAttempts: 0, + revealLastError: null, + revealNextRetryAt: null, + revealPropagatedAt: null, + nextHopBaseUrl: + outgoing && typeof outgoing.baseUrl === 'string' + ? outgoing.baseUrl + : '-', + nextHopEndpoint: + outgoing && typeof outgoing.endpoint === 'string' + ? outgoing.endpoint + : '/counterparty/transfer', + resultJson: body, + error: error.message, + createdAt: now, + updatedAt: now, + }); + } + + writeJson(response, error.statusCode, body); + return; + } console.error( - `[stackflow-node] /counterparty/signature-request error: ${ - error instanceof Error ? error.message : 'failed to sign request' + `[stackflow-node] /forwarding/transfer error: ${ + error instanceof Error ? error.message : 'failed to process forwarding transfer' }`, ); writeJson(response, 500, { ok: false, error: - error instanceof Error ? error.message : 'failed to sign request', + error instanceof Error + ? error.message + : 'failed to process forwarding transfer', + }); + } + return; + } + + if (method === 'POST' && url.pathname === '/forwarding/reveal') { + let peerMetadata: PeerRequestMetadata; + try { + peerMetadata = parsePeerRequestMetadata(request); + } catch (error) { + if (error instanceof PeerProtocolError) { + writeJson(response, error.statusCode, { + ok: false, + error: error.message, + reason: error.reason, + }); + return; + } + writeJson(response, 400, { + ok: false, + error: 'invalid peer protocol headers', + reason: 'invalid-peer-protocol', + }); + return; + } + + try { + const payload = await readJsonBody(request); + const requestHash = hashRequestPayload(payload); + const existing = stateStore.getIdempotentResponse( + url.pathname, + peerMetadata.idempotencyKey, + ); + + if (existing) { + if (existing.requestHash !== requestHash) { + writeJson( + response, + 409, + withPeerProtocolMeta( + { + ok: false, + error: 'idempotency key reuse with different payload', + reason: 'idempotency-key-reused', + }, + peerMetadata, + ), + ); + return; + } + + writeJson( + response, + existing.statusCode, + existing.responseJson, + { 'x-stackflow-idempotency-replay': 'true' }, + ); + return; + } + + const rateLimit = consumeWriteRateLimit(request); + if (rateLimit.limited) { + writeJson( + response, + 429, + withPeerProtocolMeta( + { + ok: false, + error: 'write rate limit exceeded', + reason: 'rate-limit-exceeded', + retryAfterSeconds: rateLimit.retryAfterSeconds, + }, + peerMetadata, + ), + { 'retry-after': String(rateLimit.retryAfterSeconds) }, + ); + return; + } + + if (!isRecord(payload)) { + writeJson( + response, + 400, + withPeerProtocolMeta( + { + ok: false, + error: 'payload must be an object', + reason: 'invalid-payload', + }, + peerMetadata, + ), + ); + return; + } + + const paymentId = + typeof payload.paymentId === 'string' ? payload.paymentId.trim() : ''; + if (!paymentId) { + writeJson( + response, + 400, + withPeerProtocolMeta( + { + ok: false, + error: 'paymentId is required', + reason: 'missing-payment-id', + }, + peerMetadata, + ), + ); + return; + } + + const existingPayment = stateStore.getForwardingPayment(paymentId); + if (!existingPayment) { + writeJson( + response, + 404, + withPeerProtocolMeta( + { + ok: false, + error: 'forwarding payment not found', + reason: 'payment-not-found', + }, + peerMetadata, + ), + ); + return; + } + + if (!existingPayment.hashedSecret) { + writeJson( + response, + 409, + withPeerProtocolMeta( + { + ok: false, + error: 'payment does not use hashed secret', + reason: 'payment-without-hashed-secret', + }, + peerMetadata, + ), + ); + return; + } + + const reveal = forwardingService.verifyRevealSecret({ + hashedSecret: existingPayment.hashedSecret, + secret: payload.secret, + }); + + if ( + existingPayment.revealedSecret && + existingPayment.revealedSecret !== reveal.secret + ) { + writeJson( + response, + 409, + withPeerProtocolMeta( + { + ok: false, + error: 'payment already revealed with a different secret', + reason: 'reveal-secret-mismatch', + }, + peerMetadata, + ), + ); + return; + } + + const now = new Date().toISOString(); + stateStore.setForwardingPayment({ + ...existingPayment, + revealedSecret: reveal.secret, + revealedAt: now, + updatedAt: now, + }); + + const propagatedPayment = await propagateReveal({ + payment: { + ...existingPayment, + revealedSecret: reveal.secret, + revealedAt: now, + updatedAt: now, + }, + secret: reveal.secret, + trigger: 'api', + }); + + const responseBody = withPeerProtocolMeta( + { + ok: true, + paymentId, + hashedSecret: reveal.hashedSecret, + secretRevealed: true, + revealedAt: now, + revealPropagationStatus: propagatedPayment.revealPropagationStatus, + revealPropagationAttempts: propagatedPayment.revealPropagationAttempts, + revealNextRetryAt: propagatedPayment.revealNextRetryAt, + revealLastError: propagatedPayment.revealLastError, + revealPropagatedAt: propagatedPayment.revealPropagatedAt, + }, + peerMetadata, + ); + + stateStore.setIdempotentResponse({ + endpoint: url.pathname, + idempotencyKey: peerMetadata.idempotencyKey, + requestHash, + statusCode: 200, + responseJson: responseBody, + createdAt: now, + }); + writeJson(response, 200, responseBody); + } catch (error) { + if (error instanceof ForwardingServiceError) { + const details = + error.details && typeof error.details === 'object' + ? error.details + : {}; + writeJson( + response, + error.statusCode, + withPeerProtocolMeta( + { + ok: false, + error: error.message, + ...details, + }, + peerMetadata, + ), + ); + return; + } + + writeJson(response, 500, { + ok: false, + error: error instanceof Error ? error.message : 'failed to reveal secret', }); } return; } if (method === 'POST' && url.pathname === '/new_block') { + const sourceCheck = isObserverSourceAllowed(request, observerIngressPolicy); + if (!sourceCheck.allowed) { + console.warn( + `[stackflow-node] /new_block rejected status=403 reason=observer-source-not-allowed sourceIp=${ + sourceCheck.sourceIp ?? '-' + }`, + ); + writeJson(response, 403, { + ok: false, + error: 'observer source not allowed', + reason: 'observer-source-not-allowed', + }); + return; + } + try { const payload = await readJsonBody(request); console.log( @@ -832,6 +2276,21 @@ function createHandler({ } if (method === 'POST' && url.pathname === '/new_burn_block') { + const sourceCheck = isObserverSourceAllowed(request, observerIngressPolicy); + if (!sourceCheck.allowed) { + console.warn( + `[stackflow-node] /new_burn_block rejected status=403 reason=observer-source-not-allowed sourceIp=${ + sourceCheck.sourceIp ?? '-' + }`, + ); + writeJson(response, 403, { + ok: false, + error: 'observer source not allowed', + reason: 'observer-source-not-allowed', + }); + return; + } + try { const payload = await readJsonBody(request); const burnBlockHeight = extractBurnBlockHeight(payload); @@ -957,12 +2416,55 @@ async function start(): Promise { stackflowNode, signer: counterpartySigner, }); + const forwardingService = new ForwardingService({ + counterpartyService, + config: { + enabled: config.forwardingEnabled, + minFee: config.forwardingMinFee, + timeoutMs: config.forwardingTimeoutMs, + allowPrivateDestinations: config.forwardingAllowPrivateDestinations, + allowedBaseUrls: config.forwardingAllowedBaseUrls, + }, + }); + + const propagateReveal = async ({ + payment, + secret, + trigger, + }: { + payment: ForwardingPaymentRecord; + secret: string; + trigger: 'api' | 'retry'; + }): Promise => + propagateRevealForPayment({ + payment, + secret, + trigger, + forwardingService, + stateStore, + retryIntervalMs: config.forwardingRevealRetryIntervalMs, + maxAttempts: config.forwardingRevealRetryMaxAttempts, + }); + + const observerIngressPolicy = buildObserverIngressPolicy({ + observerLocalhostOnly: config.observerLocalhostOnly, + observerAllowedIps: config.observerAllowedIps, + }); + const sensitiveReadPolicy: SensitiveReadPolicy = { + adminToken: config.adminReadToken, + localhostOnlyWithoutToken: config.adminReadLocalhostOnly, + redactWithoutToken: config.redactSensitiveReadData, + trustProxy: config.trustProxy, + }; const startedAt = new Date().toISOString(); const server = http.createServer( createHandler({ stackflowNode, + stateStore, counterpartyService, + forwardingService, + propagateReveal, startedAt, disputeEnabled: disputeExecutor.enabled, signerAddress: disputeExecutor.signerAddress, @@ -971,9 +2473,82 @@ async function start(): Promise { stacksNetwork: config.stacksNetwork, watchedContracts: config.watchedContracts, logRawEvents: config.logRawEvents, + peerWriteRateLimitPerMinute: config.peerWriteRateLimitPerMinute, + trustProxy: config.trustProxy, + observerIngressPolicy, + sensitiveReadPolicy, + forwardingAllowPrivateDestinations: config.forwardingAllowPrivateDestinations, }), ); + let retryPassRunning = false; + const runRevealRetryPass = async (): Promise => { + if (retryPassRunning) { + return; + } + retryPassRunning = true; + try { + const nowIso = new Date().toISOString(); + const due = stateStore.listForwardingRevealRetriesDue( + nowIso, + FORWARDING_REVEAL_RETRY_BATCH_SIZE, + ); + if (due.length === 0) { + return; + } + + console.log( + `[stackflow-node] reveal retry pass due=${due.length} intervalMs=${config.forwardingRevealRetryIntervalMs}`, + ); + for (const payment of due) { + if (!payment.revealedSecret) { + const updated = { + ...payment, + revealPropagationStatus: 'failed' as const, + revealPropagationAttempts: payment.revealPropagationAttempts + 1, + revealLastError: 'missing revealed secret', + revealNextRetryAt: null, + revealPropagatedAt: null, + updatedAt: new Date().toISOString(), + }; + stateStore.setForwardingPayment(updated); + console.warn( + `[stackflow-node] reveal retry paymentId=${payment.paymentId} failed: missing revealed secret`, + ); + continue; + } + + await propagateReveal({ + payment, + secret: payment.revealedSecret, + trigger: 'retry', + }); + } + } finally { + retryPassRunning = false; + } + }; + + const retryInterval = setInterval(() => { + void runRevealRetryPass().catch((error) => { + console.error( + `[stackflow-node] reveal retry pass error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, config.forwardingRevealRetryIntervalMs); + retryInterval.unref(); + setTimeout(() => { + void runRevealRetryPass().catch((error) => { + console.error( + `[stackflow-node] reveal startup retry pass error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + }, 200).unref(); + server.listen(config.port, config.host, () => { const watchedContracts = config.watchedContracts.length > 0 @@ -983,6 +2558,18 @@ async function start(): Promise { effectiveWatchedPrincipals.length > 0 ? effectiveWatchedPrincipals.join(', ') : '[auto: any principal]'; + const observerPolicyDescription = + observerIngressPolicy.allowedIps.size > 0 + ? `allowlist(${[...observerIngressPolicy.allowedIps].join(',')})` + : observerIngressPolicy.localhostOnly + ? 'localhost-only' + : 'unrestricted'; + const adminReadPolicyDescription = config.adminReadToken + ? 'token-required' + : config.adminReadLocalhostOnly + ? 'localhost-only' + : 'unrestricted'; + const publicBind = isPublicBindHost(config.host); console.log( `[stackflow-node] listening on http://${config.host}:${config.port} ` + @@ -990,6 +2577,12 @@ async function start(): Promise { `principals=${watchedPrincipals} disputes=${disputeExecutor.enabled ? 'enabled' : 'disabled'} ` + `dispute-mode=${config.disputeExecutorMode} verifier-mode=${config.signatureVerifierMode} ` + `counterparty-signer-mode=${config.counterpartySignerMode} ` + + `peer-write-rpm=${config.peerWriteRateLimitPerMinute} trust-proxy=${config.trustProxy} ` + + `public-bind=${publicBind} ` + + `observer-source-policy=${observerPolicyDescription} ` + + `admin-read-policy=${adminReadPolicyDescription} admin-read-redaction=${config.redactSensitiveReadData} ` + + `forwarding=${forwardingService.enabled ? 'enabled' : 'disabled'} forwarding-min-fee=${config.forwardingMinFee} forwarding-allow-private=${config.forwardingAllowPrivateDestinations} ` + + `forwarding-reveal-retry-ms=${config.forwardingRevealRetryIntervalMs} forwarding-reveal-max-attempts=${config.forwardingRevealRetryMaxAttempts} ` + `counterparty-signing=${counterpartyService.enabled ? 'enabled' : 'disabled'} counterparty-principal=${ counterpartyService.counterpartyPrincipal ?? '-' }`, @@ -1011,6 +2604,34 @@ async function start(): Promise { console.warn('[stackflow-node] raw stackflow event logging is enabled'); } + if (publicBind) { + console.warn( + '[stackflow-node] public bind host in use; require TLS termination, authentication, and source/IP controls at ingress', + ); + if (!config.adminReadToken && !config.adminReadLocalhostOnly) { + console.warn( + '[stackflow-node] sensitive read endpoints are unrestricted on a public bind host; configure STACKFLOW_NODE_ADMIN_READ_TOKEN or localhost-only mode', + ); + } + if (!config.observerLocalhostOnly && observerIngressPolicy.allowedIps.size === 0) { + console.warn( + '[stackflow-node] observer endpoints are unrestricted on a public bind host; configure STACKFLOW_NODE_OBSERVER_ALLOWED_IPS', + ); + } + } + + if (config.trustProxy) { + console.warn( + '[stackflow-node] trust-proxy mode enabled; x-forwarded-for is used for rate-limit and admin-localhost checks', + ); + } + + if (config.adminReadToken) { + console.warn( + '[stackflow-node] admin read token configured for sensitive inspection endpoints', + ); + } + if (counterpartySigner.counterpartyPrincipal) { if (config.watchedPrincipals.length === 0) { console.warn( @@ -1032,6 +2653,7 @@ async function start(): Promise { shuttingDown = true; console.log(`[stackflow-node] received ${signal}, shutting down`); + clearInterval(retryInterval); server.close(() => { stateStore.close(); console.log('[stackflow-node] shutdown complete'); diff --git a/server/src/signature-verifier.ts b/server/src/signature-verifier.ts index 90defb0..48ca223 100644 --- a/server/src/signature-verifier.ts +++ b/server/src/signature-verifier.ts @@ -18,6 +18,8 @@ import type { StackflowNodeConfig, } from './types.js'; +const ACTION_TRANSFER = '1'; + const STACKFLOW_CONTRACT_ERROR_MESSAGES: Record = { '100': 'deposit failed', '101': 'no such pipe', @@ -128,13 +130,31 @@ export class ReadOnlySignatureVerifier implements SignatureVerifier { signature: string, signer: string, ): Promise => { + const isTransfer = input.action === ACTION_TRANSFER; const response = await fetchCallReadOnlyFunction({ network: this.network, senderAddress: senderAddressForPrincipal(input.forPrincipal), contractAddress: contract.address, contractName: contract.name, - functionName: 'verify-signature-request', - functionArgs: functionArgs(signature, signer), + functionName: isTransfer ? 'verify-signature' : 'verify-signature-request', + functionArgs: isTransfer + ? [ + bufferCV(hexToBytes(signature)), + principalCV(signer), + tupleCV({ + token: tokenArg, + 'principal-1': principalCV(pipeKey['principal-1']), + 'principal-2': principalCV(pipeKey['principal-2']), + }), + uintCV(BigInt(balance1)), + uintCV(BigInt(balance2)), + uintCV(BigInt(input.nonce)), + uintCV(BigInt(input.action)), + principalCV(input.actor), + secretArg, + validAfterArg, + ] + : functionArgs(signature, signer), }); if (response.type === ClarityType.ResponseErr) { diff --git a/server/src/stackflow-node.ts b/server/src/stackflow-node.ts index e2459e9..9a3780b 100644 --- a/server/src/stackflow-node.ts +++ b/server/src/stackflow-node.ts @@ -42,6 +42,7 @@ interface UpsertSignatureStateOptions { const OPEN_CLOSURE_EVENTS = new Set(['force-cancel', 'force-close']); const TERMINAL_EVENTS = new Set(['close-pipe', 'dispute-closure', 'finalize']); +const ACTION_TRANSFER = '1'; const ACTION_DEPOSIT = '2'; const ACTION_WITHDRAWAL = '3'; @@ -179,6 +180,11 @@ function parseSignatureStateInput( const withPrincipal = parsePrincipal(data.withPrincipal, 'withPrincipal'); const token = normalizeToken(data.token); const action = parseUInt(data.action); + const hashedSecret = normalizeOptionalHexBuff(data.hashedSecret, 32, 'hashedSecret'); + const secret = normalizeOptionalHexBuff(data.secret, 32, 'secret'); + if (hashedSecret && secret && hashedSecret !== secret) { + throw new Error('hashedSecret and secret must match when both are provided'); + } const amount = action === ACTION_DEPOSIT || action === ACTION_WITHDRAWAL ? parseUInt(data.amount) @@ -197,7 +203,9 @@ function parseSignatureStateInput( nonce: parseUInt(data.nonce), action, actor: parsePrincipal(data.actor, 'actor'), - secret: normalizeOptionalHexBuff(data.secret, 32, 'secret'), + secret: action === ACTION_TRANSFER + ? (hashedSecret ?? secret) + : secret, validAfter: parseOptionalUInt(data.validAfter), beneficialOnly: normalizeBool(data.beneficialOnly, defaultBeneficialOnly), }; @@ -699,6 +707,11 @@ export class StackflowNode { const useBeneficialPolicy = this.disputeOnlyBeneficial || state.beneficialOnly; if (!useBeneficialPolicy) { + if (state.action === ACTION_TRANSFER && state.secret) { + if (this.stateStore.hasForwardingPaymentHash(state.secret)) { + return this.stateStore.getRevealedSecretByHash(state.secret) !== null; + } + } return true; } @@ -707,6 +720,14 @@ export class StackflowNode { return false; } + if (state.action === ACTION_TRANSFER && state.secret) { + if (this.stateStore.hasForwardingPaymentHash(state.secret)) { + if (this.stateStore.getRevealedSecretByHash(state.secret) === null) { + return false; + } + } + } + return BigInt(state.myBalance) > BigInt(closureBalance); }); @@ -734,8 +755,13 @@ export class StackflowNode { ); try { + const resolvedSecret = + eligible.action === ACTION_TRANSFER && eligible.secret + ? (this.stateStore.getRevealedSecretByHash(eligible.secret) ?? eligible.secret) + : eligible.secret; const result = await this.disputeExecutor.submitDispute({ signatureState: eligible, + resolvedSecret, closure, triggerEvent, }); diff --git a/server/src/state-store.ts b/server/src/state-store.ts index 88b6d25..ffaa6d2 100644 --- a/server/src/state-store.ts +++ b/server/src/state-store.ts @@ -5,6 +5,8 @@ import { DatabaseSync } from 'node:sqlite'; import type { ClosureRecord, DisputeAttemptRecord, + ForwardingPaymentRecord, + IdempotentResponseRecord, ObservedPipeRecord, PipeKey, RecordedStackflowNodeEvent, @@ -17,6 +19,11 @@ interface SqliteStateStoreOptions { maxRecentEvents?: number; } +const IDEMPOTENT_RESPONSE_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const IDEMPOTENT_RESPONSE_MAX_ROWS = 50_000; +const FORWARDING_ORPHAN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const FORWARDING_ORPHAN_MAX_ROWS = 5_000; + interface ClosureRow { pipe_id: string; contract_id: string; @@ -83,10 +90,66 @@ interface DisputeAttemptRow { created_at: string; } +interface IdempotentResponseRow { + endpoint: string; + idempotency_key: string; + request_hash: string; + status_code: number; + response_json: string; + created_at: string; +} + +interface ForwardingPaymentRow { + payment_id: string; + contract_id: string | null; + pipe_id: string | null; + pipe_nonce: string | null; + status: string; + incoming_amount: string; + outgoing_amount: string; + fee_amount: string; + hashed_secret: string | null; + revealed_secret: string | null; + revealed_at: string | null; + upstream_base_url: string | null; + upstream_reveal_endpoint: string | null; + upstream_payment_id: string | null; + reveal_propagation_status: string; + reveal_propagation_attempts: number; + reveal_last_error: string | null; + reveal_next_retry_at: string | null; + reveal_propagated_at: string | null; + next_hop_base_url: string; + next_hop_endpoint: string; + result_json: string; + error: string | null; + created_at: string; + updated_at: string; +} + +interface RevealedSecretRow { + hashed_secret: string; + revealed_secret: string; + first_revealed_at: string; + last_revealed_at: string; +} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } +function parseUnsignedBigInt(value: string | null): bigint | null { + if (value === null || !/^\d+$/.test(value)) { + return null; + } + + try { + return BigInt(value); + } catch { + return null; + } +} + function isLegacyState(value: unknown): value is StackflowNodePersistedState { if (!isRecord(value)) { return false; @@ -247,10 +310,68 @@ export class SqliteStateStore { event_json TEXT NOT NULL, observed_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS idempotent_responses ( + endpoint TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_hash TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (endpoint, idempotency_key) + ); + + CREATE INDEX IF NOT EXISTS idx_idempotent_responses_created_at + ON idempotent_responses(created_at DESC); + + CREATE TABLE IF NOT EXISTS forwarding_payments ( + payment_id TEXT PRIMARY KEY, + contract_id TEXT, + pipe_id TEXT, + pipe_nonce TEXT, + status TEXT NOT NULL, + incoming_amount TEXT NOT NULL, + outgoing_amount TEXT NOT NULL, + fee_amount TEXT NOT NULL, + hashed_secret TEXT, + revealed_secret TEXT, + revealed_at TEXT, + upstream_base_url TEXT, + upstream_reveal_endpoint TEXT, + upstream_payment_id TEXT, + reveal_propagation_status TEXT NOT NULL DEFAULT 'not-applicable', + reveal_propagation_attempts INTEGER NOT NULL DEFAULT 0, + reveal_last_error TEXT, + reveal_next_retry_at TEXT, + reveal_propagated_at TEXT, + next_hop_base_url TEXT NOT NULL, + next_hop_endpoint TEXT NOT NULL, + result_json TEXT NOT NULL, + error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_forwarding_payments_updated_at + ON forwarding_payments(updated_at DESC); + + CREATE INDEX IF NOT EXISTS idx_forwarding_payments_pipe + ON forwarding_payments(contract_id, pipe_id, updated_at DESC); + + CREATE INDEX IF NOT EXISTS idx_forwarding_payments_reveal_retry + ON forwarding_payments(reveal_propagation_status, reveal_next_retry_at); + + CREATE TABLE IF NOT EXISTS revealed_secrets ( + hashed_secret TEXT PRIMARY KEY, + revealed_secret TEXT NOT NULL, + first_revealed_at TEXT NOT NULL, + last_revealed_at TEXT NOT NULL + ); `); this.ensureObservedPipeColumns(); this.ensureSignatureStateColumns(); + this.ensureForwardingPaymentColumns(); const setMeta = this.db.prepare( 'INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)', @@ -261,6 +382,10 @@ export class SqliteStateStore { if (legacyState) { this.importLegacyState(legacyState); } + + this.pruneIdempotentResponses(); + this.pruneForwardingPaymentOrphans(); + this.pruneForwardingPaymentsLatestPerPipe(); } close(): void { @@ -314,6 +439,62 @@ export class SqliteStateStore { } } + private ensureForwardingPaymentColumns(): void { + const db = this.getDb(); + const columns = db + .prepare('PRAGMA table_info(forwarding_payments)') + .all() as Array<{ name: string }>; + const existing = new Set(columns.map((column) => column.name)); + + const required: Array<{ name: string; type: string }> = [ + { name: 'contract_id', type: 'TEXT' }, + { name: 'pipe_id', type: 'TEXT' }, + { name: 'pipe_nonce', type: 'TEXT' }, + { name: 'hashed_secret', type: 'TEXT' }, + { name: 'revealed_secret', type: 'TEXT' }, + { name: 'revealed_at', type: 'TEXT' }, + { name: 'upstream_base_url', type: 'TEXT' }, + { name: 'upstream_reveal_endpoint', type: 'TEXT' }, + { name: 'upstream_payment_id', type: 'TEXT' }, + { + name: 'reveal_propagation_status', + type: "TEXT NOT NULL DEFAULT 'not-applicable'", + }, + { name: 'reveal_propagation_attempts', type: 'INTEGER NOT NULL DEFAULT 0' }, + { name: 'reveal_last_error', type: 'TEXT' }, + { name: 'reveal_next_retry_at', type: 'TEXT' }, + { name: 'reveal_propagated_at', type: 'TEXT' }, + ]; + + for (const column of required) { + if (existing.has(column.name)) { + continue; + } + db.exec( + `ALTER TABLE forwarding_payments ADD COLUMN ${column.name} ${column.type}`, + ); + } + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_forwarding_payments_pipe + ON forwarding_payments(contract_id, pipe_id, updated_at DESC) + `); + + db.exec(` + CREATE INDEX IF NOT EXISTS idx_forwarding_payments_reveal_retry + ON forwarding_payments(reveal_propagation_status, reveal_next_retry_at) + `); + + db.exec(` + CREATE TABLE IF NOT EXISTS revealed_secrets ( + hashed_secret TEXT PRIMARY KEY, + revealed_secret TEXT NOT NULL, + first_revealed_at TEXT NOT NULL, + last_revealed_at TEXT NOT NULL + ) + `); + } + private loadLegacyJsonState(): StackflowNodePersistedState | null { if (!fs.existsSync(this.dbFile)) { return null; @@ -621,6 +802,244 @@ export class SqliteStateStore { }; } + private mapIdempotentResponseRow( + row: IdempotentResponseRow, + ): IdempotentResponseRecord | null { + try { + const parsed = JSON.parse(row.response_json); + if (!isRecord(parsed)) { + return null; + } + + return { + endpoint: row.endpoint, + idempotencyKey: row.idempotency_key, + requestHash: row.request_hash, + statusCode: row.status_code, + responseJson: parsed, + createdAt: row.created_at, + }; + } catch { + return null; + } + } + + private mapForwardingPaymentRow( + row: ForwardingPaymentRow, + ): ForwardingPaymentRecord | null { + if (row.status !== 'completed' && row.status !== 'failed') { + return null; + } + + if ( + row.reveal_propagation_status !== 'not-applicable' && + row.reveal_propagation_status !== 'pending' && + row.reveal_propagation_status !== 'propagated' && + row.reveal_propagation_status !== 'failed' + ) { + return null; + } + + try { + const parsed = JSON.parse(row.result_json); + if (!isRecord(parsed)) { + return null; + } + + return { + paymentId: row.payment_id, + contractId: row.contract_id, + pipeId: row.pipe_id, + pipeNonce: row.pipe_nonce, + status: row.status, + incomingAmount: row.incoming_amount, + outgoingAmount: row.outgoing_amount, + feeAmount: row.fee_amount, + hashedSecret: row.hashed_secret, + revealedSecret: row.revealed_secret, + revealedAt: row.revealed_at, + upstreamBaseUrl: row.upstream_base_url, + upstreamRevealEndpoint: row.upstream_reveal_endpoint, + upstreamPaymentId: row.upstream_payment_id, + revealPropagationStatus: row.reveal_propagation_status, + revealPropagationAttempts: Math.max( + 0, + Number.isFinite(row.reveal_propagation_attempts) + ? row.reveal_propagation_attempts + : 0, + ), + revealLastError: row.reveal_last_error, + revealNextRetryAt: row.reveal_next_retry_at, + revealPropagatedAt: row.reveal_propagated_at, + nextHopBaseUrl: row.next_hop_base_url, + nextHopEndpoint: row.next_hop_endpoint, + resultJson: parsed, + error: row.error, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } catch { + return null; + } + } + + private syncRevealedSecret(record: ForwardingPaymentRecord): void { + if (!record.hashedSecret || !record.revealedSecret) { + return; + } + + const db = this.getDb(); + const observedAt = record.revealedAt ?? record.updatedAt; + db.prepare(` + INSERT INTO revealed_secrets ( + hashed_secret, + revealed_secret, + first_revealed_at, + last_revealed_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(hashed_secret) DO UPDATE SET + revealed_secret = excluded.revealed_secret, + last_revealed_at = excluded.last_revealed_at + `).run( + record.hashedSecret, + record.revealedSecret, + observedAt, + observedAt, + ); + } + + private pruneIdempotentResponses(): void { + const db = this.getDb(); + const cutoffIso = new Date(Date.now() - IDEMPOTENT_RESPONSE_MAX_AGE_MS).toISOString(); + db.prepare(` + DELETE FROM idempotent_responses + WHERE created_at < ? + `).run(cutoffIso); + + db.prepare(` + DELETE FROM idempotent_responses + WHERE rowid NOT IN ( + SELECT rowid FROM idempotent_responses + ORDER BY created_at DESC + LIMIT ? + ) + `).run(IDEMPOTENT_RESPONSE_MAX_ROWS); + } + + private pruneForwardingPaymentOrphans(): void { + const db = this.getDb(); + const cutoffIso = new Date(Date.now() - FORWARDING_ORPHAN_MAX_AGE_MS).toISOString(); + db.prepare(` + DELETE FROM forwarding_payments + WHERE ( + contract_id IS NULL OR contract_id = '' OR + pipe_id IS NULL OR pipe_id = '' OR + pipe_nonce IS NULL OR pipe_nonce = '' + ) + AND reveal_propagation_status <> 'pending' + AND updated_at < ? + `).run(cutoffIso); + + db.prepare(` + DELETE FROM forwarding_payments + WHERE payment_id IN ( + SELECT payment_id + FROM forwarding_payments + WHERE ( + contract_id IS NULL OR contract_id = '' OR + pipe_id IS NULL OR pipe_id = '' OR + pipe_nonce IS NULL OR pipe_nonce = '' + ) + AND reveal_propagation_status <> 'pending' + ORDER BY updated_at DESC + LIMIT -1 OFFSET ? + ) + `).run(FORWARDING_ORPHAN_MAX_ROWS); + } + + private selectLatestForwardingPaymentId( + rows: Array<{ payment_id: string; pipe_nonce: string | null; updated_at: string }>, + ): string | null { + let keep: { paymentId: string; nonce: bigint | null; updatedAt: string } | null = null; + for (const row of rows) { + const candidate = { + paymentId: row.payment_id, + nonce: parseUnsignedBigInt(row.pipe_nonce), + updatedAt: row.updated_at, + }; + if (!keep) { + keep = candidate; + continue; + } + + if (candidate.nonce !== null && keep.nonce === null) { + keep = candidate; + continue; + } + if (candidate.nonce === null && keep.nonce !== null) { + continue; + } + + if (candidate.nonce !== null && keep.nonce !== null) { + if (candidate.nonce > keep.nonce) { + keep = candidate; + continue; + } + if (candidate.nonce < keep.nonce) { + continue; + } + } + + if (candidate.updatedAt > keep.updatedAt) { + keep = candidate; + } + } + + return keep?.paymentId ?? null; + } + + private pruneForwardingPaymentsForPipe(contractId: string, pipeId: string): void { + const db = this.getDb(); + const rows = db.prepare(` + SELECT payment_id, pipe_nonce, updated_at + FROM forwarding_payments + WHERE contract_id = ? AND pipe_id = ? + ORDER BY updated_at DESC + `).all(contractId, pipeId) as Array<{ + payment_id: string; + pipe_nonce: string | null; + updated_at: string; + }>; + + if (rows.length <= 1) { + return; + } + + const keepPaymentId = this.selectLatestForwardingPaymentId(rows); + if (!keepPaymentId) { + return; + } + + db.prepare(` + DELETE FROM forwarding_payments + WHERE contract_id = ? AND pipe_id = ? AND payment_id <> ? + `).run(contractId, pipeId, keepPaymentId); + } + + private pruneForwardingPaymentsLatestPerPipe(): void { + const db = this.getDb(); + const pipes = db.prepare(` + SELECT DISTINCT contract_id, pipe_id + FROM forwarding_payments + WHERE contract_id IS NOT NULL AND contract_id <> '' + AND pipe_id IS NOT NULL AND pipe_id <> '' + `).all() as Array<{ contract_id: string; pipe_id: string }>; + + for (const pipe of pipes) { + this.pruneForwardingPaymentsForPipe(pipe.contract_id, pipe.pipe_id); + } + } + getSnapshot(): StackflowNodePersistedState { const db = this.getDb(); @@ -1006,4 +1425,241 @@ export class SqliteStateStore { .all() as unknown as DisputeAttemptRow[]; return rows.map((row) => this.mapDisputeAttemptRow(row)); } + + getIdempotentResponse( + endpoint: string, + idempotencyKey: string, + ): IdempotentResponseRecord | null { + const db = this.getDb(); + const row = db + .prepare(` + SELECT * FROM idempotent_responses + WHERE endpoint = ? AND idempotency_key = ? + `) + .get(endpoint, idempotencyKey) as IdempotentResponseRow | undefined; + if (!row) { + return null; + } + return this.mapIdempotentResponseRow(row); + } + + setIdempotentResponse(response: IdempotentResponseRecord): boolean { + const db = this.getDb(); + const result = db.prepare(` + INSERT OR IGNORE INTO idempotent_responses ( + endpoint, + idempotency_key, + request_hash, + status_code, + response_json, + created_at + ) VALUES (?, ?, ?, ?, ?, ?) + `).run( + response.endpoint, + response.idempotencyKey, + response.requestHash, + response.statusCode, + JSON.stringify(response.responseJson), + response.createdAt, + ); + + if (result.changes > 0) { + this.pruneIdempotentResponses(); + this.touchUpdatedAt(); + return true; + } + + return false; + } + + setForwardingPayment(record: ForwardingPaymentRecord): void { + const db = this.getDb(); + db.prepare(` + INSERT INTO forwarding_payments ( + payment_id, + contract_id, + pipe_id, + pipe_nonce, + status, + incoming_amount, + outgoing_amount, + fee_amount, + hashed_secret, + revealed_secret, + revealed_at, + upstream_base_url, + upstream_reveal_endpoint, + upstream_payment_id, + reveal_propagation_status, + reveal_propagation_attempts, + reveal_last_error, + reveal_next_retry_at, + reveal_propagated_at, + next_hop_base_url, + next_hop_endpoint, + result_json, + error, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(payment_id) DO UPDATE SET + contract_id = excluded.contract_id, + pipe_id = excluded.pipe_id, + pipe_nonce = excluded.pipe_nonce, + status = excluded.status, + incoming_amount = excluded.incoming_amount, + outgoing_amount = excluded.outgoing_amount, + fee_amount = excluded.fee_amount, + hashed_secret = excluded.hashed_secret, + revealed_secret = excluded.revealed_secret, + revealed_at = excluded.revealed_at, + upstream_base_url = excluded.upstream_base_url, + upstream_reveal_endpoint = excluded.upstream_reveal_endpoint, + upstream_payment_id = excluded.upstream_payment_id, + reveal_propagation_status = excluded.reveal_propagation_status, + reveal_propagation_attempts = excluded.reveal_propagation_attempts, + reveal_last_error = excluded.reveal_last_error, + reveal_next_retry_at = excluded.reveal_next_retry_at, + reveal_propagated_at = excluded.reveal_propagated_at, + next_hop_base_url = excluded.next_hop_base_url, + next_hop_endpoint = excluded.next_hop_endpoint, + result_json = excluded.result_json, + error = excluded.error, + updated_at = excluded.updated_at + `).run( + record.paymentId, + record.contractId, + record.pipeId, + record.pipeNonce, + record.status, + record.incomingAmount, + record.outgoingAmount, + record.feeAmount, + record.hashedSecret, + record.revealedSecret, + record.revealedAt, + record.upstreamBaseUrl, + record.upstreamRevealEndpoint, + record.upstreamPaymentId, + record.revealPropagationStatus, + record.revealPropagationAttempts, + record.revealLastError, + record.revealNextRetryAt, + record.revealPropagatedAt, + record.nextHopBaseUrl, + record.nextHopEndpoint, + JSON.stringify(record.resultJson), + record.error, + record.createdAt, + record.updatedAt, + ); + this.syncRevealedSecret(record); + if (record.contractId && record.pipeId) { + this.pruneForwardingPaymentsForPipe(record.contractId, record.pipeId); + } else { + this.pruneForwardingPaymentOrphans(); + } + this.touchUpdatedAt(); + } + + getForwardingPayment(paymentId: string): ForwardingPaymentRecord | null { + const db = this.getDb(); + const row = db.prepare(` + SELECT * FROM forwarding_payments + WHERE payment_id = ? + `).get(paymentId) as ForwardingPaymentRow | undefined; + + if (!row) { + return null; + } + return this.mapForwardingPaymentRow(row); + } + + listForwardingPayments(limit = 100): ForwardingPaymentRecord[] { + const db = this.getDb(); + const normalizedLimit = Math.max(1, Math.min(limit, 500)); + const rows = db.prepare(` + SELECT * FROM forwarding_payments + ORDER BY updated_at DESC + LIMIT ? + `).all(normalizedLimit) as unknown as ForwardingPaymentRow[]; + + const out: ForwardingPaymentRecord[] = []; + for (const row of rows) { + const mapped = this.mapForwardingPaymentRow(row); + if (mapped) { + out.push(mapped); + } + } + return out; + } + + listForwardingRevealRetriesDue( + nowIso: string, + limit = 25, + ): ForwardingPaymentRecord[] { + const db = this.getDb(); + const normalizedLimit = Math.max(1, Math.min(limit, 500)); + const rows = db.prepare(` + SELECT * FROM forwarding_payments + WHERE reveal_propagation_status = 'pending' + AND reveal_next_retry_at IS NOT NULL + AND reveal_next_retry_at <= ? + ORDER BY reveal_next_retry_at ASC, updated_at ASC + LIMIT ? + `).all(nowIso, normalizedLimit) as unknown as ForwardingPaymentRow[]; + + const out: ForwardingPaymentRecord[] = []; + for (const row of rows) { + const mapped = this.mapForwardingPaymentRow(row); + if (mapped) { + out.push(mapped); + } + } + return out; + } + + getRevealedSecretByHash(hashedSecret: string): string | null { + const db = this.getDb(); + const revealed = db.prepare(` + SELECT revealed_secret + FROM revealed_secrets + WHERE hashed_secret = ? + LIMIT 1 + `).get(hashedSecret) as RevealedSecretRow | undefined; + if (revealed?.revealed_secret) { + return revealed.revealed_secret; + } + + const row = db.prepare(` + SELECT revealed_secret + FROM forwarding_payments + WHERE hashed_secret = ? AND revealed_secret IS NOT NULL + ORDER BY updated_at DESC + LIMIT 1 + `).get(hashedSecret) as { revealed_secret: string } | undefined; + + return row?.revealed_secret ?? null; + } + + hasForwardingPaymentHash(hashedSecret: string): boolean { + const db = this.getDb(); + const revealed = db.prepare(` + SELECT 1 as present + FROM revealed_secrets + WHERE hashed_secret = ? + LIMIT 1 + `).get(hashedSecret) as { present: number } | undefined; + if (Boolean(revealed?.present)) { + return true; + } + + const row = db.prepare(` + SELECT 1 as present + FROM forwarding_payments + WHERE hashed_secret = ? + LIMIT 1 + `).get(hashedSecret) as { present: number } | undefined; + return Boolean(row?.present); + } } diff --git a/server/src/types.ts b/server/src/types.ts index 743eb10..d5ea737 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -145,6 +145,43 @@ export interface StackflowNodePersistedState { recentEvents: RecordedStackflowNodeEvent[]; } +export interface IdempotentResponseRecord { + endpoint: string; + idempotencyKey: string; + requestHash: string; + statusCode: number; + responseJson: Record; + createdAt: string; +} + +export interface ForwardingPaymentRecord { + paymentId: string; + contractId: string | null; + pipeId: string | null; + pipeNonce: string | null; + status: 'completed' | 'failed'; + incomingAmount: string; + outgoingAmount: string; + feeAmount: string; + hashedSecret: string | null; + revealedSecret: string | null; + revealedAt: string | null; + upstreamBaseUrl: string | null; + upstreamRevealEndpoint: string | null; + upstreamPaymentId: string | null; + revealPropagationStatus: 'not-applicable' | 'pending' | 'propagated' | 'failed'; + revealPropagationAttempts: number; + revealLastError: string | null; + revealNextRetryAt: string | null; + revealPropagatedAt: string | null; + nextHopBaseUrl: string; + nextHopEndpoint: string; + resultJson: Record; + error: string | null; + createdAt: string; + updatedAt: string; +} + export interface RecordedStackflowNodeEvent extends StackflowPrintEvent { source: string | null; observedAt: string; @@ -200,6 +237,20 @@ export interface StackflowNodeConfig { signatureVerifierMode: SignatureVerifierMode; disputeExecutorMode: DisputeExecutorMode; disputeOnlyBeneficial: boolean; + peerWriteRateLimitPerMinute: number; + trustProxy: boolean; + observerLocalhostOnly: boolean; + observerAllowedIps: string[]; + adminReadToken: string | null; + adminReadLocalhostOnly: boolean; + redactSensitiveReadData: boolean; + forwardingEnabled: boolean; + forwardingMinFee: string; + forwardingTimeoutMs: number; + forwardingAllowPrivateDestinations: boolean; + forwardingAllowedBaseUrls: string[]; + forwardingRevealRetryIntervalMs: number; + forwardingRevealRetryMaxAttempts: number; } export interface SubmitDisputeResult { @@ -211,6 +262,7 @@ export interface DisputeExecutor { readonly signerAddress: string | null; submitDispute(args: { signatureState: SignatureStateRecord; + resolvedSecret: string | null; closure: ClosureRecord; triggerEvent: StackflowPrintEvent; }): Promise; diff --git a/server/ui/main.js b/server/ui/main.js index 826efe5..1ee0179 100644 --- a/server/ui/main.js +++ b/server/ui/main.js @@ -12,10 +12,12 @@ var CHAIN_IDS = { devnet: 2147483648n, mocknet: 2147483648n }; +var PEER_PROTOCOL_VERSION = "1"; var STORAGE_KEY = "stackflow-console-config-v1"; var connectedAddress = null; var stackflowNodeCounterpartyEnabled = false; var stackflowNodeCounterpartyPrincipal = null; +var peerRequestCounter = 0; var ids = { serverUrl: "stackflow-node-url", contractId: "contract-id", @@ -398,6 +400,16 @@ function updateActionUi() { function normalizedText(value) { return String(value || "").trim(); } +function createPeerProtocolHeaders() { + peerRequestCounter += 1; + const seed = `${Date.now().toString(36)}-${peerRequestCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return { + "content-type": "application/json", + "x-stackflow-protocol-version": PEER_PROTOCOL_VERSION, + "x-stackflow-request-id": `req-${seed}`, + "idempotency-key": `idem-${seed}` + }; +} function splitContractPrincipal(contractId) { const value = normalizedText(contractId); const parts = value.split("."); @@ -1221,7 +1233,7 @@ async function requestCounterpartySignature(action) { const requestPayload = buildCounterpartyRequestPayload(action); const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: createPeerProtocolHeaders(), body: JSON.stringify(requestPayload.payload) }); const body = await response.json().catch(() => ({})); @@ -1235,6 +1247,14 @@ async function requestCounterpartySignature(action) { ); return; } + if (response.status === 409 && body?.reason === "idempotency-key-reused") { + setStatus( + ids.txResult, + "Counterparty request rejected: idempotency key was reused with a different payload.", + true + ); + return; + } if (!response.ok) { const message = typeof body?.error === "string" ? body.error : `${response.status} ${response.statusText}`; throw new Error(message); diff --git a/server/ui/main.src.js b/server/ui/main.src.js index 2f49c19..36689a6 100644 --- a/server/ui/main.src.js +++ b/server/ui/main.src.js @@ -12,12 +12,14 @@ const CHAIN_IDS = { devnet: 2147483648n, mocknet: 2147483648n, }; +const PEER_PROTOCOL_VERSION = "1"; const STORAGE_KEY = "stackflow-console-config-v1"; let connectedAddress = null; let stackflowNodeCounterpartyEnabled = false; let stackflowNodeCounterpartyPrincipal = null; +let peerRequestCounter = 0; const ids = { serverUrl: "stackflow-node-url", @@ -429,6 +431,17 @@ function normalizedText(value) { return String(value || "").trim(); } +function createPeerProtocolHeaders() { + peerRequestCounter += 1; + const seed = `${Date.now().toString(36)}-${peerRequestCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + return { + "content-type": "application/json", + "x-stackflow-protocol-version": PEER_PROTOCOL_VERSION, + "x-stackflow-request-id": `req-${seed}`, + "idempotency-key": `idem-${seed}`, + }; +} + function splitContractPrincipal(contractId) { const value = normalizedText(contractId); const parts = value.split("."); @@ -1447,7 +1460,7 @@ async function requestCounterpartySignature(action) { const requestPayload = buildCounterpartyRequestPayload(action); const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: createPeerProtocolHeaders(), body: JSON.stringify(requestPayload.payload), }); const body = await response.json().catch(() => ({})); @@ -1464,6 +1477,15 @@ async function requestCounterpartySignature(action) { return; } + if (response.status === 409 && body?.reason === "idempotency-key-reused") { + setStatus( + ids.txResult, + "Counterparty request rejected: idempotency key was reused with a different payload.", + true, + ); + return; + } + if (!response.ok) { const message = typeof body?.error === "string" diff --git a/settings/Devnet.toml b/settings/Devnet.toml index d2b166c..e9a0efd 100644 --- a/settings/Devnet.toml +++ b/settings/Devnet.toml @@ -1,3 +1,7 @@ +# SECURITY NOTE: +# This file contains deterministic Clarinet devnet fixture mnemonics/keys. +# They are public test fixtures only and must never be used in production. + [network] name = "devnet" deployment_fee_rate = 10 @@ -154,4 +158,3 @@ auto_extend = true wallet = "wallet_3" slots = 2 btc_address = "mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7" - diff --git a/tests/watchtower-dispute.test.ts b/tests/stackflow-node-dispute.test.ts similarity index 100% rename from tests/watchtower-dispute.test.ts rename to tests/stackflow-node-dispute.test.ts diff --git a/tests/stackflow-node-http.integration.test.ts b/tests/stackflow-node-http.integration.test.ts new file mode 100644 index 0000000..298c17e --- /dev/null +++ b/tests/stackflow-node-http.integration.test.ts @@ -0,0 +1,2047 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { + noneCV, + principalCV, + serializeCV, + stringAsciiCV, + tupleCV, + uintCV, +} from '@stacks/transactions'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const ROOT = process.cwd(); +const SERVER_ENTRY = path.join(ROOT, 'server', 'dist', 'index.js'); +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; +const COUNTERPARTY_SIGNER_KEY = + '7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801'; +const SIG_A = `0x${'11'.repeat(65)}`; +const SIG_B = `0x${'22'.repeat(65)}`; +const ADMIN_READ_TOKEN = 'stackflow-admin-integration-token'; +const RUN_HTTP_INTEGRATION = process.env.STACKFLOW_NODE_HTTP_INTEGRATION === '1'; + +interface Harness { + baseUrl: string; + dbFile: string; + logs: () => string; + stop: () => Promise; + restart: () => Promise; +} + +interface MockNextHop { + baseUrl: string; + requests: Array<{ + headers: http.IncomingHttpHeaders; + body: unknown; + }>; + revealRequests: Array<{ + headers: http.IncomingHttpHeaders; + body: unknown; + }>; + stop: () => Promise; +} + +let built = false; + +beforeAll(() => { + if (!RUN_HTTP_INTEGRATION) { + return; + } + + if (!built) { + execFileSync('npm', ['run', '-s', 'build:stackflow-node'], { + cwd: ROOT, + stdio: 'pipe', + }); + built = true; + } +}); + +function cleanupDbFiles(dbFile: string): void { + for (const suffix of ['', '-wal', '-shm']) { + const file = `${dbFile}${suffix}`; + if (fs.existsSync(file)) { + fs.unlinkSync(file); + } + } +} + +function forceEventHex({ + eventName, + sender, + nonce, + balance1, + balance2, +}: { + eventName: 'force-close' | 'force-cancel'; + sender: string; + nonce: number; + balance1: number; + balance2: number; +}): string { + return `0x${serializeCV( + tupleCV({ + event: stringAsciiCV(eventName), + sender: principalCV(sender), + 'pipe-key': tupleCV({ + 'principal-1': principalCV(P1), + 'principal-2': principalCV(P2), + token: noneCV(), + }), + pipe: tupleCV({ + 'balance-1': uintCV(balance1), + 'balance-2': uintCV(balance2), + 'expires-at': uintCV(5000), + nonce: uintCV(nonce), + closer: noneCV(), + }), + }), + )}`; +} + +function newBlockPayload({ + txid, + eventName, + sender, + nonce, + balance1, + balance2, +}: { + txid: string; + eventName: 'force-close' | 'force-cancel'; + sender: string; + nonce: number; + balance1: number; + balance2: number; +}) { + return { + block_height: 555, + events: [ + { + txid, + contract_event: { + contract_identifier: CONTRACT_ID, + topic: 'print', + raw_value: forceEventHex({ + eventName, + sender, + nonce, + balance1, + balance2, + }), + }, + }, + ], + }; +} + +function signatureStatePayload(forPrincipal: string) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal: forPrincipal === P1 ? P2 : P1, + token: null, + myBalance: '900', + theirBalance: '100', + mySignature: SIG_A, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: forPrincipal, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +function transferPayload({ + forPrincipal, + withPrincipal, + myBalance, + theirBalance, + nonce, + hashedSecret, +}: { + forPrincipal: string; + withPrincipal: string; + myBalance: string; + theirBalance: string; + nonce: string; + hashedSecret?: string; +}) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal, + token: null, + amount: '0', + myBalance, + theirBalance, + theirSignature: SIG_B, + nonce, + action: '1', + actor: withPrincipal, + ...(hashedSecret + ? { + hashedSecret, + secret: hashedSecret, + } + : { secret: null }), + validAfter: null, + beneficialOnly: false, + }; +} + +function signatureRequestPayload({ + forPrincipal, + withPrincipal, + action, + amount, + myBalance, + theirBalance, + nonce, + actor, +}: { + forPrincipal: string; + withPrincipal: string; + action: '0' | '2' | '3'; + amount: string; + myBalance: string; + theirBalance: string; + nonce: string; + actor: string; +}) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal, + token: null, + amount, + myBalance, + theirBalance, + theirSignature: SIG_B, + nonce, + action, + actor, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +function forwardingPayload({ + paymentId, + incomingAmount, + outgoingAmount, + hashedSecret, + incoming, + outgoingBaseUrl, + outgoingEndpoint, + outgoingPayload, + upstream, +}: { + paymentId: string; + incomingAmount: string; + outgoingAmount: string; + hashedSecret: string; + incoming: Record; + outgoingBaseUrl: string; + outgoingEndpoint?: string; + outgoingPayload: Record; + upstream?: { + baseUrl: string; + paymentId: string; + revealEndpoint?: string; + }; +}) { + return { + paymentId, + incomingAmount, + outgoingAmount, + hashedSecret, + incoming, + ...(upstream + ? { + upstream: { + baseUrl: upstream.baseUrl, + paymentId: upstream.paymentId, + revealEndpoint: upstream.revealEndpoint ?? '/forwarding/reveal', + }, + } + : {}), + outgoing: { + baseUrl: outgoingBaseUrl, + endpoint: outgoingEndpoint ?? '/counterparty/transfer', + payload: outgoingPayload, + }, + }; +} + +function peerHeaders(seed: string): Record { + return { + 'content-type': 'application/json', + 'x-stackflow-protocol-version': '1', + 'x-stackflow-request-id': `req-${seed}`, + 'idempotency-key': `idem-${seed}`, + }; +} + +function adminHeaders(token = ADMIN_READ_TOKEN): Record { + return { + 'x-stackflow-admin-token': token, + }; +} + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('failed to allocate port')); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + server.on('error', reject); + }); +} + +async function waitForCondition( + predicate: () => Promise | boolean, + timeoutMs = 4000, + intervalMs = 100, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const done = await predicate(); + if (done) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error('condition timed out'); +} + +async function waitForHealth( + baseUrl: string, + child: ReturnType, + logsRef: string[], +): Promise { + const deadline = Date.now() + 10000; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `watchtower exited before health check. logs:\n${logsRef.join('')}`, + ); + } + + try { + const response = await fetch(`${baseUrl}/health`); + if (response.status === 200) { + return; + } + } catch { + // ignore + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`watchtower health timeout. logs:\n${logsRef.join('')}`); +} + +async function startHarness({ + dbFile, + extraEnv, +}: { + dbFile: string; + extraEnv: Record; +}): Promise { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const logsRef: string[] = []; + let child = spawn('node', [SERVER_ENTRY], { + cwd: ROOT, + env: { + ...process.env, + STACKFLOW_NODE_HOST: '127.0.0.1', + STACKFLOW_NODE_PORT: String(port), + STACKFLOW_NODE_DB_FILE: dbFile, + STACKFLOW_CONTRACTS: CONTRACT_ID, + ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + + await waitForHealth(baseUrl, child, logsRef); + + const stop = async (): Promise => { + if (child.exitCode !== null) { + return; + } + child.kill('SIGTERM'); + await once(child, 'exit'); + }; + + const restart = async (): Promise => { + await stop(); + child = spawn('node', [SERVER_ENTRY], { + cwd: ROOT, + env: { + ...process.env, + STACKFLOW_NODE_HOST: '127.0.0.1', + STACKFLOW_NODE_PORT: String(port), + STACKFLOW_NODE_DB_FILE: dbFile, + STACKFLOW_CONTRACTS: CONTRACT_ID, + ...extraEnv, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + logsRef.push(chunk.toString('utf8')); + }); + await waitForHealth(baseUrl, child, logsRef); + }; + + return { + baseUrl, + dbFile, + logs: () => logsRef.join(''), + stop, + restart, + }; +} + +async function startMockNextHop(options: { + failRevealAttempts?: number; +} = {}): Promise { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const requests: Array<{ + headers: http.IncomingHttpHeaders; + body: unknown; + }> = []; + const revealRequests: Array<{ + headers: http.IncomingHttpHeaders; + body: unknown; + }> = []; + let revealFailuresRemaining = Math.max(0, options.failRevealAttempts ?? 0); + + const server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + let body: unknown = {}; + if (chunks.length > 0) { + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + body = {}; + } + } + + const pathname = new URL(request.url || '/', baseUrl).pathname; + if (pathname === '/counterparty/transfer') { + requests.push({ + headers: request.headers, + body, + }); + + response.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + }); + response.end( + JSON.stringify({ + ok: true, + mySignature: SIG_A, + theirSignature: SIG_B, + stored: true, + replaced: false, + nonce: '11', + action: '1', + }), + ); + return; + } + + if (pathname === '/forwarding/reveal') { + revealRequests.push({ + headers: request.headers, + body, + }); + if (revealFailuresRemaining > 0) { + revealFailuresRemaining -= 1; + response.writeHead(503, { + 'content-type': 'application/json; charset=utf-8', + }); + response.end( + JSON.stringify({ + ok: false, + error: 'temporary upstream failure', + reason: 'temporary-unavailable', + }), + ); + return; + } + + response.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + }); + response.end( + JSON.stringify({ + ok: true, + secretRevealed: true, + }), + ); + return; + } + + response.writeHead(404, { + 'content-type': 'application/json; charset=utf-8', + }); + response.end( + JSON.stringify({ + ok: false, + error: 'route not found', + }), + ); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => resolve()); + }); + + return { + baseUrl, + requests, + revealRequests, + stop: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +const describeHttp = RUN_HTTP_INTEGRATION + ? describe.sequential + : describe.skip; + +describeHttp('watchtower http integration', () => { + it('supports stacks-node observer routes and persists closures across restart', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: `${P1},${P2}`, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + }, + }); + + try { + const badRoute = await fetch(`${harness.baseUrl}/ingest`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(badRoute.status).toBe(404); + + const app = await fetch(`${harness.baseUrl}/app`); + expect(app.status).toBe(200); + + const appScript = await fetch(`${harness.baseUrl}/app/main.js`); + expect(appScript.status).toBe(200); + + const compatRoutes = [ + '/new_burn_block', + '/new_mempool_tx', + '/drop_mempool_tx', + '/new_microblocks', + ]; + for (const route of compatRoutes) { + const response = await fetch(`${harness.baseUrl}${route}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { + ok: boolean; + ignored: boolean; + route: string; + }; + expect(body.ok).toBe(true); + expect(body.ignored).toBe(true); + expect(body.route).toBe(route); + } + + const ingest = await fetch(`${harness.baseUrl}/new_block`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + newBlockPayload({ + txid: '0xevent1', + eventName: 'force-close', + sender: P1, + nonce: 4, + balance1: 500, + balance2: 500, + }), + ), + }); + expect(ingest.status).toBe(200); + const ingestBody = (await ingest.json()) as { + ok: boolean; + observedEvents: number; + }; + expect(ingestBody.ok).toBe(true); + expect(ingestBody.observedEvents).toBe(1); + + const closuresResponse = await fetch(`${harness.baseUrl}/closures`); + const closures = (await closuresResponse.json()) as { + closures: Array<{ event: string }>; + }; + expect(closures.closures).toHaveLength(1); + expect(closures.closures[0].event).toBe('force-close'); + + const pipesResponse = await fetch( + `${harness.baseUrl}/pipes?principal=${encodeURIComponent(P1)}`, + ); + expect(pipesResponse.status).toBe(200); + const pipes = (await pipesResponse.json()) as { + pipes: Array<{ + event: string; + balance1: string | null; + balance2: string | null; + nonce: string | null; + }>; + }; + expect(pipes.pipes).toHaveLength(1); + expect(pipes.pipes[0].event).toBe('force-close'); + expect(pipes.pipes[0].balance1).toBe('500'); + expect(pipes.pipes[0].balance2).toBe('500'); + expect(pipes.pipes[0].nonce).toBe('4'); + + await harness.restart(); + + const closuresAfterRestartResponse = await fetch( + `${harness.baseUrl}/closures`, + ); + const closuresAfterRestart = (await closuresAfterRestartResponse.json()) as { + closures: Array<{ event: string }>; + }; + expect(closuresAfterRestart.closures).toHaveLength(1); + expect(closuresAfterRestart.closures[0].event).toBe('force-close'); + } finally { + await harness.stop(); + + const db = new DatabaseSync(dbFile); + const closureCount = db + .prepare('SELECT COUNT(*) as count FROM closures') + .get() as { count: number }; + db.close(); + expect(closureCount.count).toBe(1); + + cleanupDbFiles(dbFile); + } + }); + + it('restricts observer routes to configured source IPs and ignores x-forwarded-for spoofing', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: `${P1},${P2}`, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY: 'false', + STACKFLOW_NODE_OBSERVER_ALLOWED_IPS: '198.51.100.77', + }, + }); + + try { + const blockResponse = await fetch(`${harness.baseUrl}/new_block`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': '198.51.100.77', + }, + body: JSON.stringify( + newBlockPayload({ + txid: '0xevent-observer-denied-1', + eventName: 'force-close', + sender: P1, + nonce: 4, + balance1: 500, + balance2: 500, + }), + ), + }); + expect(blockResponse.status).toBe(403); + const blockBody = (await blockResponse.json()) as { + ok: boolean; + reason: string; + }; + expect(blockBody.ok).toBe(false); + expect(blockBody.reason).toBe('observer-source-not-allowed'); + + const burnResponse = await fetch(`${harness.baseUrl}/new_burn_block`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': '198.51.100.77', + }, + body: JSON.stringify({ block_height: 700 }), + }); + expect(burnResponse.status).toBe(403); + const burnBody = (await burnResponse.json()) as { + ok: boolean; + reason: string; + }; + expect(burnBody.ok).toBe(false); + expect(burnBody.reason).toBe('observer-source-not-allowed'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('runs end-to-end signature ingest and mock dispute flow', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'mock', + }, + }); + + try { + const malformed = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + expect(malformed.status).toBe(400); + + const unwatched = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P3)), + }); + expect(unwatched.status).toBe(403); + + const accepted = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(accepted.status).toBe(200); + const acceptedBody = (await accepted.json()) as { stored: boolean }; + expect(acceptedBody.stored).toBe(true); + + const duplicateNonce = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(duplicateNonce.status).toBe(409); + const duplicateNonceBody = (await duplicateNonce.json()) as { + ok: boolean; + reason: string; + existingNonce: string; + }; + expect(duplicateNonceBody.ok).toBe(false); + expect(duplicateNonceBody.reason).toBe('nonce-too-low'); + expect(duplicateNonceBody.existingNonce).toBe('5'); + + const trigger = await fetch(`${harness.baseUrl}/new_block`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + newBlockPayload({ + txid: '0xevent2', + eventName: 'force-cancel', + sender: P2, + nonce: 3, + balance1: 500, + balance2: 500, + }), + ), + }); + expect(trigger.status).toBe(200); + + const pipesResponse = await fetch( + `${harness.baseUrl}/pipes?principal=${encodeURIComponent(P1)}`, + ); + expect(pipesResponse.status).toBe(200); + const pipes = (await pipesResponse.json()) as { + pipes: Array<{ + event: string; + source: string; + balance1: string | null; + balance2: string | null; + nonce: string | null; + }>; + }; + expect(pipes.pipes).toHaveLength(1); + expect(pipes.pipes[0].event).toBe('signature-state'); + expect(pipes.pipes[0].source).toBe('signature-state'); + expect(pipes.pipes[0].balance1).toBe('900'); + expect(pipes.pipes[0].balance2).toBe('100'); + expect(pipes.pipes[0].nonce).toBe('5'); + + const disputesResponse = await fetch( + `${harness.baseUrl}/dispute-attempts?limit=10`, + ); + const disputes = (await disputesResponse.json()) as { + disputeAttempts: Array<{ + success: boolean; + disputeTxid: string | null; + }>; + }; + expect(disputes.disputeAttempts).toHaveLength(1); + expect(disputes.disputeAttempts[0].success).toBe(true); + expect(disputes.disputeAttempts[0].disputeTxid).toMatch(/^0xmock/); + + await harness.restart(); + + const statesAfterRestartResponse = await fetch( + `${harness.baseUrl}/signature-states?limit=10`, + ); + const statesAfterRestart = (await statesAfterRestartResponse.json()) as { + signatureStates: Array<{ forPrincipal: string }>; + }; + expect(statesAfterRestart.signatureStates).toHaveLength(1); + expect(statesAfterRestart.signatureStates[0].forPrincipal).toBe(P1); + } finally { + await harness.stop(); + + const db = new DatabaseSync(dbFile); + const stateCount = db + .prepare('SELECT COUNT(*) as count FROM signature_states') + .get() as { count: number }; + const attemptCount = db + .prepare('SELECT COUNT(*) as count FROM dispute_attempts') + .get() as { count: number }; + db.close(); + + expect(stateCount.count).toBe(1); + expect(attemptCount.count).toBe(1); + cleanupDbFiles(dbFile); + } + }); + + it('signs and persists a direct transfer update through /counterparty/transfer', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + STACKFLOW_NODE_ADMIN_READ_TOKEN: ADMIN_READ_TOKEN, + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const transferResponse = await fetch( + `${harness.baseUrl}/counterparty/transfer`, + { + method: 'POST', + headers: peerHeaders('transfer-1'), + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '6', + }), + ), + }, + ); + expect(transferResponse.status).toBe(200); + const transferBody = (await transferResponse.json()) as { + stored: boolean; + replaced: boolean; + mySignature: string; + protocolVersion: string; + idempotencyKey: string; + requestId: string; + }; + expect(transferBody.stored).toBe(true); + expect(transferBody.replaced).toBe(true); + expect(transferBody.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + expect(transferBody.protocolVersion).toBe('1'); + expect(transferBody.idempotencyKey).toBe('idem-transfer-1'); + expect(transferBody.requestId).toBe('req-transfer-1'); + + const replayTransferResponse = await fetch( + `${harness.baseUrl}/counterparty/transfer`, + { + method: 'POST', + headers: peerHeaders('transfer-1'), + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '6', + }), + ), + }, + ); + expect(replayTransferResponse.status).toBe(200); + expect(replayTransferResponse.headers.get('x-stackflow-idempotency-replay')).toBe( + 'true', + ); + const replayTransferBody = (await replayTransferResponse.json()) as { + mySignature: string; + }; + expect(replayTransferBody.mySignature).toBe(transferBody.mySignature); + + const idempotencyReuseResponse = await fetch( + `${harness.baseUrl}/counterparty/transfer`, + { + method: 'POST', + headers: peerHeaders('transfer-1'), + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '920', + theirBalance: '80', + nonce: '7', + }), + ), + }, + ); + expect(idempotencyReuseResponse.status).toBe(409); + const idempotencyReuseBody = (await idempotencyReuseResponse.json()) as { + reason: string; + }; + expect(idempotencyReuseBody.reason).toBe('idempotency-key-reused'); + + const statesDeniedResponse = await fetch( + `${harness.baseUrl}/signature-states?limit=10`, + ); + expect(statesDeniedResponse.status).toBe(401); + + const statesResponse = await fetch(`${harness.baseUrl}/signature-states?limit=10`, { + headers: adminHeaders(), + }); + expect(statesResponse.status).toBe(200); + const statesBody = (await statesResponse.json()) as { + redacted: boolean; + signatureStates: Array<{ + forPrincipal: string; + withPrincipal: string; + nonce: string; + myBalance: string; + theirBalance: string; + mySignature: string; + }>; + }; + expect(statesBody.redacted).toBe(false); + expect(statesBody.signatureStates).toHaveLength(1); + expect(statesBody.signatureStates[0].forPrincipal).toBe(counterpartyPrincipal); + expect(statesBody.signatureStates[0].withPrincipal).toBe(withPrincipal); + expect(statesBody.signatureStates[0].nonce).toBe('6'); + expect(statesBody.signatureStates[0].myBalance).toBe('910'); + expect(statesBody.signatureStates[0].theirBalance).toBe('90'); + expect(statesBody.signatureStates[0].mySignature).toBe( + transferBody.mySignature, + ); + + const pipesResponse = await fetch( + `${harness.baseUrl}/pipes?principal=${encodeURIComponent(counterpartyPrincipal)}`, + ); + expect(pipesResponse.status).toBe(200); + const pipesBody = (await pipesResponse.json()) as { + pipes: Array<{ + source: string; + nonce: string | null; + balance1: string | null; + balance2: string | null; + pipeKey: { + 'principal-1': string; + 'principal-2': string; + }; + }>; + }; + expect(pipesBody.pipes).toHaveLength(1); + expect(pipesBody.pipes[0].source).toBe('signature-state'); + expect(pipesBody.pipes[0].nonce).toBe('6'); + const principal1IsCounterparty = + pipesBody.pipes[0].pipeKey['principal-1'] === counterpartyPrincipal; + expect( + principal1IsCounterparty + ? pipesBody.pipes[0].balance1 + : pipesBody.pipes[0].balance2, + ).toBe('910'); + expect( + principal1IsCounterparty + ? pipesBody.pipes[0].balance2 + : pipesBody.pipes[0].balance1, + ).toBe('90'); + + const rejectedTransfer = await fetch( + `${harness.baseUrl}/counterparty/transfer`, + { + method: 'POST', + headers: peerHeaders('transfer-2'), + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '7', + }), + ), + }, + ); + expect(rejectedTransfer.status).toBe(403); + const rejectedBody = (await rejectedTransfer.json()) as { + reason: string; + }; + expect(rejectedBody.reason).toBe('transfer-not-beneficial'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('supports peer signature requests for close, deposit, and withdrawal', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const closeResponse = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: peerHeaders('sig-close-1'), + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '0', + amount: '0', + myBalance: '900', + theirBalance: '100', + nonce: '6', + actor: withPrincipal, + }), + ), + }, + ); + expect(closeResponse.status).toBe(200); + const closeBody = (await closeResponse.json()) as { + action: string; + nonce: string; + stored: boolean; + replaced: boolean; + mySignature: string; + }; + expect(closeBody.action).toBe('0'); + expect(closeBody.nonce).toBe('6'); + expect(closeBody.stored).toBe(true); + expect(closeBody.replaced).toBe(true); + expect(closeBody.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + + const depositResponse = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: peerHeaders('sig-deposit-1'), + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '2', + amount: '50', + myBalance: '900', + theirBalance: '150', + nonce: '7', + actor: withPrincipal, + }), + ), + }, + ); + expect(depositResponse.status).toBe(200); + const depositBody = (await depositResponse.json()) as { + action: string; + nonce: string; + }; + expect(depositBody.action).toBe('2'); + expect(depositBody.nonce).toBe('7'); + + const withdrawalResponse = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: peerHeaders('sig-withdraw-1'), + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '3', + amount: '25', + myBalance: '900', + theirBalance: '125', + nonce: '8', + actor: withPrincipal, + }), + ), + }, + ); + expect(withdrawalResponse.status).toBe(200); + const withdrawalBody = (await withdrawalResponse.json()) as { + action: string; + nonce: string; + }; + expect(withdrawalBody.action).toBe('3'); + expect(withdrawalBody.nonce).toBe('8'); + + const duplicateNonce = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: peerHeaders('sig-close-2'), + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '0', + amount: '0', + myBalance: '900', + theirBalance: '125', + nonce: '8', + actor: withPrincipal, + }), + ), + }, + ); + expect(duplicateNonce.status).toBe(409); + const duplicateNonceBody = (await duplicateNonce.json()) as { + reason: string; + }; + expect(duplicateNonceBody.reason).toBe('nonce-too-low'); + + const balanceDecrease = await fetch( + `${harness.baseUrl}/counterparty/signature-request`, + { + method: 'POST', + headers: peerHeaders('sig-close-3'), + body: JSON.stringify( + signatureRequestPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + action: '0', + amount: '0', + myBalance: '899', + theirBalance: '126', + nonce: '9', + actor: withPrincipal, + }), + ), + }, + ); + expect(balanceDecrease.status).toBe(403); + const balanceDecreaseBody = (await balanceDecrease.json()) as { + reason: string; + }; + expect(balanceDecreaseBody.reason).toBe('counterparty-balance-decrease'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('rejects counterparty requests missing peer protocol headers', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const response = await fetch(`${harness.baseUrl}/counterparty/transfer`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( + transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '6', + }), + ), + }); + expect(response.status).toBe(400); + const body = (await response.json()) as { reason: string }; + expect(body.reason).toBe('missing-protocol-version'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('processes a forwarding transfer and persists payment records', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const nextHop = await startMockNextHop(); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + STACKFLOW_NODE_FORWARDING_ENABLED: 'true', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '5', + STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS: 'true', + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: nextHop.baseUrl, + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + forwardingEnabled: boolean; + }; + expect(health.forwardingEnabled).toBe(true); + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const payload = forwardingPayload({ + paymentId: 'pay-2026-02-28-0001', + incomingAmount: '100', + outgoingAmount: '90', + hashedSecret: '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '6', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + }), + outgoingBaseUrl: nextHop.baseUrl, + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '11', + action: '1', + actor: counterpartyPrincipal, + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }); + + const forwardResponse = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-1'), + body: JSON.stringify(payload), + }); + expect(forwardResponse.status).toBe(200); + const forwardBody = (await forwardResponse.json()) as { + paymentId: string; + feeAmount: string; + hashedSecret: string; + upstream: { + mySignature: string; + }; + }; + expect(forwardBody.paymentId).toBe('pay-2026-02-28-0001'); + expect(forwardBody.feeAmount).toBe('10'); + expect(forwardBody.hashedSecret).toBe( + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + ); + expect(forwardBody.upstream.mySignature).toMatch(/^0x[0-9a-f]{130}$/); + expect(nextHop.requests).toHaveLength(1); + expect(nextHop.requests[0].headers['x-stackflow-protocol-version']).toBe('1'); + expect(nextHop.requests[0].headers['x-stackflow-request-id']).toBeTruthy(); + expect(nextHop.requests[0].headers['idempotency-key']).toBeTruthy(); + + const replayResponse = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-1'), + body: JSON.stringify(payload), + }); + expect(replayResponse.status).toBe(200); + expect(replayResponse.headers.get('x-stackflow-idempotency-replay')).toBe('true'); + expect(nextHop.requests).toHaveLength(1); + + const paymentResponse = await fetch( + `${harness.baseUrl}/forwarding/payments?paymentId=pay-2026-02-28-0001`, + ); + expect(paymentResponse.status).toBe(200); + const paymentBody = (await paymentResponse.json()) as { + redacted: boolean; + payment: { + status: string; + hashedSecret: string | null; + revealedSecret: string | null; + } | null; + }; + expect(paymentBody.redacted).toBe(true); + expect(paymentBody.payment).toBeTruthy(); + expect(paymentBody.payment?.status).toBe('completed'); + expect(paymentBody.payment?.hashedSecret).toBe( + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + ); + expect(paymentBody.payment?.revealedSecret).toBe(null); + + const revealBad = await fetch(`${harness.baseUrl}/forwarding/reveal`, { + method: 'POST', + headers: peerHeaders('forward-reveal-1'), + body: JSON.stringify({ + paymentId: 'pay-2026-02-28-0001', + secret: '0x2222222222222222222222222222222222222222222222222222222222222222', + }), + }); + expect(revealBad.status).toBe(400); + const revealBadBody = (await revealBad.json()) as { reason: string }; + expect(revealBadBody.reason).toBe('invalid-secret-preimage'); + + const revealOk = await fetch(`${harness.baseUrl}/forwarding/reveal`, { + method: 'POST', + headers: peerHeaders('forward-reveal-2'), + body: JSON.stringify({ + paymentId: 'pay-2026-02-28-0001', + secret: '0x8484848484848484848484848484848484848484848484848484848484848484', + }), + }); + expect(revealOk.status).toBe(200); + const revealOkBody = (await revealOk.json()) as { secretRevealed: boolean }; + expect(revealOkBody.secretRevealed).toBe(true); + + const paymentAfterRevealResponse = await fetch( + `${harness.baseUrl}/forwarding/payments?paymentId=pay-2026-02-28-0001`, + ); + expect(paymentAfterRevealResponse.status).toBe(200); + const paymentAfterRevealBody = (await paymentAfterRevealResponse.json()) as { + redacted: boolean; + payment: { + revealedSecret: string | null; + revealedAt: string | null; + } | null; + }; + expect(paymentAfterRevealBody.redacted).toBe(true); + expect(paymentAfterRevealBody.payment?.revealedSecret).toBe(null); + expect(paymentAfterRevealBody.payment?.revealedAt).toBeTruthy(); + + const lowFeeResponse = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-2'), + body: JSON.stringify( + forwardingPayload({ + paymentId: 'pay-2026-02-28-0002', + incomingAmount: '100', + outgoingAmount: '99', + hashedSecret: + '0x3333333333333333333333333333333333333333333333333333333333333333', + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '920', + theirBalance: '80', + nonce: '7', + hashedSecret: + '0x3333333333333333333333333333333333333333333333333333333333333333', + }), + outgoingBaseUrl: nextHop.baseUrl, + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '12', + action: '1', + actor: counterpartyPrincipal, + hashedSecret: + '0x3333333333333333333333333333333333333333333333333333333333333333', + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }), + ), + }); + expect(lowFeeResponse.status).toBe(403); + const lowFeeBody = (await lowFeeResponse.json()) as { + reason: string; + }; + expect(lowFeeBody.reason).toBe('forwarding-fee-too-low'); + expect(nextHop.requests).toHaveLength(1); + } finally { + await harness.stop(); + await nextHop.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('rejects forwarding transfers to private next-hop destinations by default', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + STACKFLOW_NODE_FORWARDING_ENABLED: 'true', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '1', + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const response = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-private-next-hop'), + body: JSON.stringify( + forwardingPayload({ + paymentId: 'pay-2026-03-01-private-1', + incomingAmount: '100', + outgoingAmount: '99', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '901', + theirBalance: '99', + nonce: '6', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + }), + outgoingBaseUrl: 'http://127.0.0.1:9797', + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '11', + action: '1', + actor: counterpartyPrincipal, + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }), + ), + }); + expect(response.status).toBe(403); + const body = (await response.json()) as { reason: string }; + expect(body.reason).toBe('next-hop-private-destination'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('rejects unsupported forwarding endpoint paths', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + STACKFLOW_NODE_FORWARDING_ENABLED: 'true', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '1', + STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS: 'true', + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const badNextHopEndpointResponse = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-bad-endpoint-1'), + body: JSON.stringify( + forwardingPayload({ + paymentId: 'pay-2026-03-01-endpoint-1', + incomingAmount: '100', + outgoingAmount: '99', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '901', + theirBalance: '99', + nonce: '6', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + }), + outgoingBaseUrl: 'http://127.0.0.1:9797', + outgoingEndpoint: '/counterparty/signature-request', + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '11', + action: '1', + actor: counterpartyPrincipal, + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }), + ), + }); + expect(badNextHopEndpointResponse.status).toBe(400); + const badNextHopEndpointBody = (await badNextHopEndpointResponse.json()) as { + reason: string; + }; + expect(badNextHopEndpointBody.reason).toBe('unsupported-next-hop-endpoint'); + + const badUpstreamEndpointResponse = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-bad-endpoint-2'), + body: JSON.stringify( + forwardingPayload({ + paymentId: 'pay-2026-03-01-endpoint-2', + incomingAmount: '100', + outgoingAmount: '99', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + upstream: { + baseUrl: 'http://127.0.0.1:8787', + paymentId: 'upstream-endpoint-2', + revealEndpoint: '/counterparty/transfer', + }, + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '902', + theirBalance: '98', + nonce: '7', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + }), + outgoingBaseUrl: 'http://127.0.0.1:9797', + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '12', + action: '1', + actor: counterpartyPrincipal, + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }), + ), + }); + expect(badUpstreamEndpointResponse.status).toBe(400); + const badUpstreamEndpointBody = (await badUpstreamEndpointResponse.json()) as { + reason: string; + }; + expect(badUpstreamEndpointBody.reason).toBe('unsupported-upstream-reveal-endpoint'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('propagates revealed secrets upstream and retries after transient failure', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const nextHop = await startMockNextHop({ failRevealAttempts: 1 }); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + STACKFLOW_NODE_FORWARDING_ENABLED: 'true', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '1', + STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS: 'true', + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: nextHop.baseUrl, + STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS: '100', + STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS: '4', + }, + }); + + try { + const healthResponse = await fetch(`${harness.baseUrl}/health`); + expect(healthResponse.status).toBe(200); + const health = (await healthResponse.json()) as { + counterpartyPrincipal: string | null; + }; + expect(typeof health.counterpartyPrincipal).toBe('string'); + const counterpartyPrincipal = health.counterpartyPrincipal as string; + const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baselineState = signatureStatePayload(counterpartyPrincipal); + baselineState.withPrincipal = withPrincipal; + baselineState.actor = withPrincipal; + const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baselineState), + }); + expect(seedResponse.status).toBe(200); + + const payload = forwardingPayload({ + paymentId: 'pay-2026-02-28-0009', + incomingAmount: '101', + outgoingAmount: '100', + hashedSecret: '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + upstream: { + baseUrl: nextHop.baseUrl, + paymentId: 'upstream-pay-0009', + }, + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal, + myBalance: '901', + theirBalance: '99', + nonce: '9', + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + }), + outgoingBaseUrl: nextHop.baseUrl, + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '19', + action: '1', + actor: counterpartyPrincipal, + hashedSecret: + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb', + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }); + + const forwardResponse = await fetch(`${harness.baseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders('forward-upstream-1'), + body: JSON.stringify(payload), + }); + expect(forwardResponse.status).toBe(200); + + const revealResponse = await fetch(`${harness.baseUrl}/forwarding/reveal`, { + method: 'POST', + headers: peerHeaders('forward-upstream-reveal-1'), + body: JSON.stringify({ + paymentId: 'pay-2026-02-28-0009', + secret: '0x8484848484848484848484848484848484848484848484848484848484848484', + }), + }); + expect(revealResponse.status).toBe(200); + const revealBody = (await revealResponse.json()) as { + revealPropagationStatus: string; + }; + expect(revealBody.revealPropagationStatus).toBe('pending'); + + await waitForCondition(async () => { + const response = await fetch( + `${harness.baseUrl}/forwarding/payments?paymentId=pay-2026-02-28-0009`, + ); + const body = (await response.json()) as { + payment: { + revealPropagationStatus: string; + revealPropagationAttempts: number; + revealPropagatedAt: string | null; + } | null; + }; + return ( + body.payment?.revealPropagationStatus === 'propagated' && + body.payment.revealPropagationAttempts >= 2 && + Boolean(body.payment.revealPropagatedAt) + ); + }); + + expect(nextHop.revealRequests.length).toBeGreaterThanOrEqual(2); + const firstRevealBody = nextHop.revealRequests[0]?.body as + | { paymentId?: string; secret?: string } + | undefined; + expect(firstRevealBody?.paymentId).toBe('upstream-pay-0009'); + expect(firstRevealBody?.secret).toBe( + '0x8484848484848484848484848484848484848484848484848484848484848484', + ); + } finally { + await harness.stop(); + await nextHop.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('returns 401 when reject-all verifier mode is active', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'reject-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + }, + }); + + try { + const response = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(response.status).toBe(401); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('returns 429 when write rate limit is exceeded', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE: '1', + }, + }); + + try { + const first = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(first.status).toBe(200); + + const second = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(second.status).toBe(429); + expect(second.headers.get('retry-after')).toBeTruthy(); + const secondBody = (await second.json()) as { reason: string }; + expect(secondBody.reason).toBe('rate-limit-exceeded'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('does not trust x-forwarded-for for rate limiting by default', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE: '1', + }, + }); + + try { + const first = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': '198.51.100.17', + }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(first.status).toBe(200); + + const second = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-forwarded-for': '203.0.113.19', + }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(second.status).toBe(429); + const secondBody = (await second.json()) as { reason: string }; + expect(secondBody.reason).toBe('rate-limit-exceeded'); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); + + it('redacts sensitive signature fields when admin token is not configured', async () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, + ); + const harness = await startHarness({ + dbFile, + extraEnv: { + STACKFLOW_NODE_PRINCIPALS: P1, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + }, + }); + + try { + const seed = await fetch(`${harness.baseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(signatureStatePayload(P1)), + }); + expect(seed.status).toBe(200); + + const response = await fetch(`${harness.baseUrl}/signature-states?limit=10`); + expect(response.status).toBe(200); + const body = (await response.json()) as { + redacted: boolean; + signatureStates: Array<{ + mySignature: string; + theirSignature: string; + secret: string | null; + }>; + }; + expect(body.redacted).toBe(true); + expect(body.signatureStates).toHaveLength(1); + expect(body.signatureStates[0].mySignature).toBe('[redacted]'); + expect(body.signatureStates[0].theirSignature).toBe('[redacted]'); + expect(body.signatureStates[0].secret).toBe(null); + } finally { + await harness.stop(); + cleanupDbFiles(dbFile); + } + }); +}); diff --git a/tests/watchtower-observer.test.ts b/tests/stackflow-node-observer.test.ts similarity index 100% rename from tests/watchtower-observer.test.ts rename to tests/stackflow-node-observer.test.ts diff --git a/tests/watchtower-state.test.ts b/tests/stackflow-node-state.test.ts similarity index 66% rename from tests/watchtower-state.test.ts rename to tests/stackflow-node-state.test.ts index 4c0ea1f..82dffb7 100644 --- a/tests/watchtower-state.test.ts +++ b/tests/stackflow-node-state.test.ts @@ -15,10 +15,12 @@ import { describe, expect, it } from 'vitest'; import { normalizePipeId } from '../server/src/observer-parser.ts'; import { SqliteStateStore } from '../server/src/state-store.ts'; import { StackflowNode } from '../server/src/stackflow-node.ts'; +import type { ForwardingPaymentRecord } from '../server/src/types.ts'; const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; function cleanupDb(store: SqliteStateStore, dbFile: string): void { store.close(); @@ -30,6 +32,44 @@ function cleanupDb(store: SqliteStateStore, dbFile: string): void { } } +function forwardingPaymentRecord(args: { + paymentId: string; + pipeId: string; + pipeNonce: string; + hashedSecret: string; + revealedSecret: string | null; + now?: string; +}): ForwardingPaymentRecord { + const now = args.now ?? new Date().toISOString(); + return { + paymentId: args.paymentId, + contractId: CONTRACT_ID, + pipeId: args.pipeId, + pipeNonce: args.pipeNonce, + status: 'completed', + incomingAmount: '100', + outgoingAmount: '99', + feeAmount: '1', + hashedSecret: args.hashedSecret, + revealedSecret: args.revealedSecret, + revealedAt: args.revealedSecret ? now : null, + upstreamBaseUrl: null, + upstreamRevealEndpoint: null, + upstreamPaymentId: null, + revealPropagationStatus: 'not-applicable', + revealPropagationAttempts: 0, + revealLastError: null, + revealNextRetryAt: null, + revealPropagatedAt: null, + nextHopBaseUrl: 'http://127.0.0.1:9797', + nextHopEndpoint: '/counterparty/transfer', + resultJson: { ok: true, paymentId: args.paymentId }, + error: null, + createdAt: now, + updatedAt: now, + }; +} + function forceEventHex( name: 'fund-pipe' | 'force-close' | 'close-pipe' | 'dispute-closure' | 'finalize', principal1: string = P1, @@ -66,8 +106,7 @@ function payloadFor( { txid: `0x${eventName}`, contract_event: { - contract_identifier: - 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + contract_identifier: CONTRACT_ID, topic: 'print', raw_value: forceEventHex(eventName, principal1, principal2), }, @@ -237,9 +276,9 @@ describe('watchtower state transitions', () => { } store.setObservedPipe({ - stateId: `ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0|${pipeId}`, + stateId: `${CONTRACT_ID}|${pipeId}`, pipeId, - contractId: 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0', + contractId: CONTRACT_ID, pipeKey, balance1: '0', balance2: '0', @@ -276,4 +315,104 @@ describe('watchtower state transitions', () => { cleanupDb(store, dbFile); }); + + it('keeps only the latest forwarding payment per pipe nonce and preserves revealed secret mapping', () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const pipeId = normalizePipeId({ + token: null, + 'principal-1': P1, + 'principal-2': P2, + }); + if (!pipeId) { + throw new Error('failed to build pipe id'); + } + + store.setForwardingPayment( + forwardingPaymentRecord({ + paymentId: 'pay-retain-0001', + pipeId, + pipeNonce: '5', + hashedSecret: + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + revealedSecret: + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }), + ); + store.setForwardingPayment( + forwardingPaymentRecord({ + paymentId: 'pay-retain-0002', + pipeId, + pipeNonce: '6', + hashedSecret: + '0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + revealedSecret: null, + }), + ); + + const oldPayment = store.getForwardingPayment('pay-retain-0001'); + expect(oldPayment).toBeNull(); + const latestPayment = store.getForwardingPayment('pay-retain-0002'); + expect(latestPayment).toBeTruthy(); + expect(latestPayment?.pipeNonce).toBe('6'); + + expect( + store.getRevealedSecretByHash( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + ).toBe('0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + expect( + store.hasForwardingPaymentHash( + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ), + ).toBe(true); + + cleanupDb(store, dbFile); + }); + + it('prunes idempotent responses older than retention window', () => { + const dbFile = path.join( + os.tmpdir(), + `stackflow-watchtower-${Date.now()}-${Math.random()}.db`, + ); + + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 10 }); + store.load(); + + const now = new Date(); + const stale = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(); + const fresh = now.toISOString(); + + store.setIdempotentResponse({ + endpoint: '/counterparty/transfer', + idempotencyKey: 'idem-prune-old-1', + requestHash: 'hash-old', + statusCode: 200, + responseJson: { ok: true }, + createdAt: stale, + }); + store.setIdempotentResponse({ + endpoint: '/counterparty/transfer', + idempotencyKey: 'idem-prune-fresh-1', + requestHash: 'hash-fresh', + statusCode: 200, + responseJson: { ok: true }, + createdAt: fresh, + }); + + expect( + store.getIdempotentResponse('/counterparty/transfer', 'idem-prune-old-1'), + ).toBeNull(); + expect( + store.getIdempotentResponse('/counterparty/transfer', 'idem-prune-fresh-1'), + ).toBeTruthy(); + + cleanupDb(store, dbFile); + }); }); diff --git a/tests/watchtower-http.integration.test.ts b/tests/watchtower-http.integration.test.ts deleted file mode 100644 index ad502c1..0000000 --- a/tests/watchtower-http.integration.test.ts +++ /dev/null @@ -1,947 +0,0 @@ -import { execFileSync, spawn } from 'node:child_process'; -import { once } from 'node:events'; -import fs from 'node:fs'; -import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { DatabaseSync } from 'node:sqlite'; - -import { - noneCV, - principalCV, - serializeCV, - stringAsciiCV, - tupleCV, - uintCV, -} from '@stacks/transactions'; -import { beforeAll, describe, expect, it } from 'vitest'; - -const ROOT = process.cwd(); -const SERVER_ENTRY = path.join(ROOT, 'server', 'dist', 'index.js'); -const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; -const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; -const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; -const P3 = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM'; -const COUNTERPARTY_SIGNER_KEY = - '7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801'; -const SIG_A = `0x${'11'.repeat(65)}`; -const SIG_B = `0x${'22'.repeat(65)}`; -const RUN_HTTP_INTEGRATION = process.env.STACKFLOW_NODE_HTTP_INTEGRATION === '1'; - -interface Harness { - baseUrl: string; - dbFile: string; - logs: () => string; - stop: () => Promise; - restart: () => Promise; -} - -let built = false; - -beforeAll(() => { - if (!RUN_HTTP_INTEGRATION) { - return; - } - - if (!built) { - execFileSync('npm', ['run', '-s', 'build:stackflow-node'], { - cwd: ROOT, - stdio: 'pipe', - }); - built = true; - } -}); - -function cleanupDbFiles(dbFile: string): void { - for (const suffix of ['', '-wal', '-shm']) { - const file = `${dbFile}${suffix}`; - if (fs.existsSync(file)) { - fs.unlinkSync(file); - } - } -} - -function forceEventHex({ - eventName, - sender, - nonce, - balance1, - balance2, -}: { - eventName: 'force-close' | 'force-cancel'; - sender: string; - nonce: number; - balance1: number; - balance2: number; -}): string { - return `0x${serializeCV( - tupleCV({ - event: stringAsciiCV(eventName), - sender: principalCV(sender), - 'pipe-key': tupleCV({ - 'principal-1': principalCV(P1), - 'principal-2': principalCV(P2), - token: noneCV(), - }), - pipe: tupleCV({ - 'balance-1': uintCV(balance1), - 'balance-2': uintCV(balance2), - 'expires-at': uintCV(5000), - nonce: uintCV(nonce), - closer: noneCV(), - }), - }), - )}`; -} - -function newBlockPayload({ - txid, - eventName, - sender, - nonce, - balance1, - balance2, -}: { - txid: string; - eventName: 'force-close' | 'force-cancel'; - sender: string; - nonce: number; - balance1: number; - balance2: number; -}) { - return { - block_height: 555, - events: [ - { - txid, - contract_event: { - contract_identifier: CONTRACT_ID, - topic: 'print', - raw_value: forceEventHex({ - eventName, - sender, - nonce, - balance1, - balance2, - }), - }, - }, - ], - }; -} - -function signatureStatePayload(forPrincipal: string) { - return { - contractId: CONTRACT_ID, - forPrincipal, - withPrincipal: forPrincipal === P1 ? P2 : P1, - token: null, - myBalance: '900', - theirBalance: '100', - mySignature: SIG_A, - theirSignature: SIG_B, - nonce: '5', - action: '1', - actor: forPrincipal, - secret: null, - validAfter: null, - beneficialOnly: false, - }; -} - -function transferPayload({ - forPrincipal, - withPrincipal, - myBalance, - theirBalance, - nonce, -}: { - forPrincipal: string; - withPrincipal: string; - myBalance: string; - theirBalance: string; - nonce: string; -}) { - return { - contractId: CONTRACT_ID, - forPrincipal, - withPrincipal, - token: null, - amount: '0', - myBalance, - theirBalance, - theirSignature: SIG_B, - nonce, - action: '1', - actor: withPrincipal, - secret: null, - validAfter: null, - beneficialOnly: false, - }; -} - -function signatureRequestPayload({ - forPrincipal, - withPrincipal, - action, - amount, - myBalance, - theirBalance, - nonce, - actor, -}: { - forPrincipal: string; - withPrincipal: string; - action: '0' | '2' | '3'; - amount: string; - myBalance: string; - theirBalance: string; - nonce: string; - actor: string; -}) { - return { - contractId: CONTRACT_ID, - forPrincipal, - withPrincipal, - token: null, - amount, - myBalance, - theirBalance, - theirSignature: SIG_B, - nonce, - action, - actor, - secret: null, - validAfter: null, - beneficialOnly: false, - }; -} - -function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - reject(new Error('failed to allocate port')); - return; - } - const { port } = address; - server.close((error) => { - if (error) { - reject(error); - return; - } - resolve(port); - }); - }); - server.on('error', reject); - }); -} - -async function waitForHealth( - baseUrl: string, - child: ReturnType, - logsRef: string[], -): Promise { - const deadline = Date.now() + 10000; - - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error( - `watchtower exited before health check. logs:\n${logsRef.join('')}`, - ); - } - - try { - const response = await fetch(`${baseUrl}/health`); - if (response.status === 200) { - return; - } - } catch { - // ignore - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - throw new Error(`watchtower health timeout. logs:\n${logsRef.join('')}`); -} - -async function startHarness({ - dbFile, - extraEnv, -}: { - dbFile: string; - extraEnv: Record; -}): Promise { - const port = await getFreePort(); - const baseUrl = `http://127.0.0.1:${port}`; - const logsRef: string[] = []; - let child = spawn('node', [SERVER_ENTRY], { - cwd: ROOT, - env: { - ...process.env, - STACKFLOW_NODE_HOST: '127.0.0.1', - STACKFLOW_NODE_PORT: String(port), - STACKFLOW_NODE_DB_FILE: dbFile, - STACKFLOW_CONTRACTS: CONTRACT_ID, - ...extraEnv, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stdout.on('data', (chunk: Buffer) => { - logsRef.push(chunk.toString('utf8')); - }); - child.stderr.on('data', (chunk: Buffer) => { - logsRef.push(chunk.toString('utf8')); - }); - - await waitForHealth(baseUrl, child, logsRef); - - const stop = async (): Promise => { - if (child.exitCode !== null) { - return; - } - child.kill('SIGTERM'); - await once(child, 'exit'); - }; - - const restart = async (): Promise => { - await stop(); - child = spawn('node', [SERVER_ENTRY], { - cwd: ROOT, - env: { - ...process.env, - STACKFLOW_NODE_HOST: '127.0.0.1', - STACKFLOW_NODE_PORT: String(port), - STACKFLOW_NODE_DB_FILE: dbFile, - STACKFLOW_CONTRACTS: CONTRACT_ID, - ...extraEnv, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout.on('data', (chunk: Buffer) => { - logsRef.push(chunk.toString('utf8')); - }); - child.stderr.on('data', (chunk: Buffer) => { - logsRef.push(chunk.toString('utf8')); - }); - await waitForHealth(baseUrl, child, logsRef); - }; - - return { - baseUrl, - dbFile, - logs: () => logsRef.join(''), - stop, - restart, - }; -} - -const describeHttp = RUN_HTTP_INTEGRATION - ? describe.sequential - : describe.skip; - -describeHttp('watchtower http integration', () => { - it('supports stacks-node observer routes and persists closures across restart', async () => { - const dbFile = path.join( - os.tmpdir(), - `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, - ); - const harness = await startHarness({ - dbFile, - extraEnv: { - STACKFLOW_NODE_PRINCIPALS: `${P1},${P2}`, - STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', - STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', - }, - }); - - try { - const badRoute = await fetch(`${harness.baseUrl}/ingest`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}), - }); - expect(badRoute.status).toBe(404); - - const app = await fetch(`${harness.baseUrl}/app`); - expect(app.status).toBe(200); - - const appScript = await fetch(`${harness.baseUrl}/app/main.js`); - expect(appScript.status).toBe(200); - - const compatRoutes = [ - '/new_burn_block', - '/new_mempool_tx', - '/drop_mempool_tx', - '/new_microblocks', - ]; - for (const route of compatRoutes) { - const response = await fetch(`${harness.baseUrl}${route}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}), - }); - expect(response.status).toBe(200); - const body = (await response.json()) as { - ok: boolean; - ignored: boolean; - route: string; - }; - expect(body.ok).toBe(true); - expect(body.ignored).toBe(true); - expect(body.route).toBe(route); - } - - const ingest = await fetch(`${harness.baseUrl}/new_block`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - newBlockPayload({ - txid: '0xevent1', - eventName: 'force-close', - sender: P1, - nonce: 4, - balance1: 500, - balance2: 500, - }), - ), - }); - expect(ingest.status).toBe(200); - const ingestBody = (await ingest.json()) as { - ok: boolean; - observedEvents: number; - }; - expect(ingestBody.ok).toBe(true); - expect(ingestBody.observedEvents).toBe(1); - - const closuresResponse = await fetch(`${harness.baseUrl}/closures`); - const closures = (await closuresResponse.json()) as { - closures: Array<{ event: string }>; - }; - expect(closures.closures).toHaveLength(1); - expect(closures.closures[0].event).toBe('force-close'); - - const pipesResponse = await fetch( - `${harness.baseUrl}/pipes?principal=${encodeURIComponent(P1)}`, - ); - expect(pipesResponse.status).toBe(200); - const pipes = (await pipesResponse.json()) as { - pipes: Array<{ - event: string; - balance1: string | null; - balance2: string | null; - nonce: string | null; - }>; - }; - expect(pipes.pipes).toHaveLength(1); - expect(pipes.pipes[0].event).toBe('force-close'); - expect(pipes.pipes[0].balance1).toBe('500'); - expect(pipes.pipes[0].balance2).toBe('500'); - expect(pipes.pipes[0].nonce).toBe('4'); - - await harness.restart(); - - const closuresAfterRestartResponse = await fetch( - `${harness.baseUrl}/closures`, - ); - const closuresAfterRestart = (await closuresAfterRestartResponse.json()) as { - closures: Array<{ event: string }>; - }; - expect(closuresAfterRestart.closures).toHaveLength(1); - expect(closuresAfterRestart.closures[0].event).toBe('force-close'); - } finally { - await harness.stop(); - - const db = new DatabaseSync(dbFile); - const closureCount = db - .prepare('SELECT COUNT(*) as count FROM closures') - .get() as { count: number }; - db.close(); - expect(closureCount.count).toBe(1); - - cleanupDbFiles(dbFile); - } - }); - - it('runs end-to-end signature ingest and mock dispute flow', async () => { - const dbFile = path.join( - os.tmpdir(), - `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, - ); - const harness = await startHarness({ - dbFile, - extraEnv: { - STACKFLOW_NODE_PRINCIPALS: P1, - STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', - STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'mock', - }, - }); - - try { - const malformed = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}), - }); - expect(malformed.status).toBe(400); - - const unwatched = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(signatureStatePayload(P3)), - }); - expect(unwatched.status).toBe(403); - - const accepted = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(signatureStatePayload(P1)), - }); - expect(accepted.status).toBe(200); - const acceptedBody = (await accepted.json()) as { stored: boolean }; - expect(acceptedBody.stored).toBe(true); - - const duplicateNonce = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(signatureStatePayload(P1)), - }); - expect(duplicateNonce.status).toBe(409); - const duplicateNonceBody = (await duplicateNonce.json()) as { - ok: boolean; - reason: string; - existingNonce: string; - }; - expect(duplicateNonceBody.ok).toBe(false); - expect(duplicateNonceBody.reason).toBe('nonce-too-low'); - expect(duplicateNonceBody.existingNonce).toBe('5'); - - const trigger = await fetch(`${harness.baseUrl}/new_block`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - newBlockPayload({ - txid: '0xevent2', - eventName: 'force-cancel', - sender: P2, - nonce: 3, - balance1: 500, - balance2: 500, - }), - ), - }); - expect(trigger.status).toBe(200); - - const pipesResponse = await fetch( - `${harness.baseUrl}/pipes?principal=${encodeURIComponent(P1)}`, - ); - expect(pipesResponse.status).toBe(200); - const pipes = (await pipesResponse.json()) as { - pipes: Array<{ - event: string; - source: string; - balance1: string | null; - balance2: string | null; - nonce: string | null; - }>; - }; - expect(pipes.pipes).toHaveLength(1); - expect(pipes.pipes[0].event).toBe('signature-state'); - expect(pipes.pipes[0].source).toBe('signature-state'); - expect(pipes.pipes[0].balance1).toBe('900'); - expect(pipes.pipes[0].balance2).toBe('100'); - expect(pipes.pipes[0].nonce).toBe('5'); - - const disputesResponse = await fetch( - `${harness.baseUrl}/dispute-attempts?limit=10`, - ); - const disputes = (await disputesResponse.json()) as { - disputeAttempts: Array<{ - success: boolean; - disputeTxid: string | null; - }>; - }; - expect(disputes.disputeAttempts).toHaveLength(1); - expect(disputes.disputeAttempts[0].success).toBe(true); - expect(disputes.disputeAttempts[0].disputeTxid).toMatch(/^0xmock/); - - await harness.restart(); - - const statesAfterRestartResponse = await fetch( - `${harness.baseUrl}/signature-states?limit=10`, - ); - const statesAfterRestart = (await statesAfterRestartResponse.json()) as { - signatureStates: Array<{ forPrincipal: string }>; - }; - expect(statesAfterRestart.signatureStates).toHaveLength(1); - expect(statesAfterRestart.signatureStates[0].forPrincipal).toBe(P1); - } finally { - await harness.stop(); - - const db = new DatabaseSync(dbFile); - const stateCount = db - .prepare('SELECT COUNT(*) as count FROM signature_states') - .get() as { count: number }; - const attemptCount = db - .prepare('SELECT COUNT(*) as count FROM dispute_attempts') - .get() as { count: number }; - db.close(); - - expect(stateCount.count).toBe(1); - expect(attemptCount.count).toBe(1); - cleanupDbFiles(dbFile); - } - }); - - it('signs and persists a direct transfer update through /counterparty/transfer', async () => { - const dbFile = path.join( - os.tmpdir(), - `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, - ); - const harness = await startHarness({ - dbFile, - extraEnv: { - STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', - STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', - STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, - }, - }); - - try { - const healthResponse = await fetch(`${harness.baseUrl}/health`); - expect(healthResponse.status).toBe(200); - const health = (await healthResponse.json()) as { - counterpartyPrincipal: string | null; - }; - expect(typeof health.counterpartyPrincipal).toBe('string'); - const counterpartyPrincipal = health.counterpartyPrincipal as string; - const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; - - const baselineState = signatureStatePayload(counterpartyPrincipal); - baselineState.withPrincipal = withPrincipal; - baselineState.actor = withPrincipal; - - const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(baselineState), - }); - expect(seedResponse.status).toBe(200); - - const transferResponse = await fetch( - `${harness.baseUrl}/counterparty/transfer`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - transferPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - myBalance: '910', - theirBalance: '90', - nonce: '6', - }), - ), - }, - ); - expect(transferResponse.status).toBe(200); - const transferBody = (await transferResponse.json()) as { - stored: boolean; - replaced: boolean; - mySignature: string; - }; - expect(transferBody.stored).toBe(true); - expect(transferBody.replaced).toBe(true); - expect(transferBody.mySignature).toMatch(/^0x[0-9a-f]{130}$/); - - const statesResponse = await fetch( - `${harness.baseUrl}/signature-states?limit=10`, - ); - expect(statesResponse.status).toBe(200); - const statesBody = (await statesResponse.json()) as { - signatureStates: Array<{ - forPrincipal: string; - withPrincipal: string; - nonce: string; - myBalance: string; - theirBalance: string; - mySignature: string; - }>; - }; - expect(statesBody.signatureStates).toHaveLength(1); - expect(statesBody.signatureStates[0].forPrincipal).toBe(counterpartyPrincipal); - expect(statesBody.signatureStates[0].withPrincipal).toBe(withPrincipal); - expect(statesBody.signatureStates[0].nonce).toBe('6'); - expect(statesBody.signatureStates[0].myBalance).toBe('910'); - expect(statesBody.signatureStates[0].theirBalance).toBe('90'); - expect(statesBody.signatureStates[0].mySignature).toBe( - transferBody.mySignature, - ); - - const pipesResponse = await fetch( - `${harness.baseUrl}/pipes?principal=${encodeURIComponent(counterpartyPrincipal)}`, - ); - expect(pipesResponse.status).toBe(200); - const pipesBody = (await pipesResponse.json()) as { - pipes: Array<{ - source: string; - nonce: string | null; - balance1: string | null; - balance2: string | null; - pipeKey: { - 'principal-1': string; - 'principal-2': string; - }; - }>; - }; - expect(pipesBody.pipes).toHaveLength(1); - expect(pipesBody.pipes[0].source).toBe('signature-state'); - expect(pipesBody.pipes[0].nonce).toBe('6'); - const principal1IsCounterparty = - pipesBody.pipes[0].pipeKey['principal-1'] === counterpartyPrincipal; - expect( - principal1IsCounterparty - ? pipesBody.pipes[0].balance1 - : pipesBody.pipes[0].balance2, - ).toBe('910'); - expect( - principal1IsCounterparty - ? pipesBody.pipes[0].balance2 - : pipesBody.pipes[0].balance1, - ).toBe('90'); - - const rejectedTransfer = await fetch( - `${harness.baseUrl}/counterparty/transfer`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - transferPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - myBalance: '910', - theirBalance: '90', - nonce: '7', - }), - ), - }, - ); - expect(rejectedTransfer.status).toBe(403); - const rejectedBody = (await rejectedTransfer.json()) as { - reason: string; - }; - expect(rejectedBody.reason).toBe('transfer-not-beneficial'); - } finally { - await harness.stop(); - cleanupDbFiles(dbFile); - } - }); - - it('supports peer signature requests for close, deposit, and withdrawal', async () => { - const dbFile = path.join( - os.tmpdir(), - `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, - ); - const harness = await startHarness({ - dbFile, - extraEnv: { - STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', - STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', - STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, - }, - }); - - try { - const healthResponse = await fetch(`${harness.baseUrl}/health`); - expect(healthResponse.status).toBe(200); - const health = (await healthResponse.json()) as { - counterpartyPrincipal: string | null; - }; - expect(typeof health.counterpartyPrincipal).toBe('string'); - const counterpartyPrincipal = health.counterpartyPrincipal as string; - const withPrincipal = counterpartyPrincipal === P1 ? P2 : P1; - - const baselineState = signatureStatePayload(counterpartyPrincipal); - baselineState.withPrincipal = withPrincipal; - baselineState.actor = withPrincipal; - - const seedResponse = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(baselineState), - }); - expect(seedResponse.status).toBe(200); - - const closeResponse = await fetch( - `${harness.baseUrl}/counterparty/signature-request`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - signatureRequestPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - action: '0', - amount: '0', - myBalance: '900', - theirBalance: '100', - nonce: '6', - actor: withPrincipal, - }), - ), - }, - ); - expect(closeResponse.status).toBe(200); - const closeBody = (await closeResponse.json()) as { - action: string; - nonce: string; - stored: boolean; - replaced: boolean; - mySignature: string; - }; - expect(closeBody.action).toBe('0'); - expect(closeBody.nonce).toBe('6'); - expect(closeBody.stored).toBe(true); - expect(closeBody.replaced).toBe(true); - expect(closeBody.mySignature).toMatch(/^0x[0-9a-f]{130}$/); - - const depositResponse = await fetch( - `${harness.baseUrl}/counterparty/signature-request`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - signatureRequestPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - action: '2', - amount: '50', - myBalance: '900', - theirBalance: '150', - nonce: '7', - actor: withPrincipal, - }), - ), - }, - ); - expect(depositResponse.status).toBe(200); - const depositBody = (await depositResponse.json()) as { - action: string; - nonce: string; - }; - expect(depositBody.action).toBe('2'); - expect(depositBody.nonce).toBe('7'); - - const withdrawalResponse = await fetch( - `${harness.baseUrl}/counterparty/signature-request`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - signatureRequestPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - action: '3', - amount: '25', - myBalance: '900', - theirBalance: '125', - nonce: '8', - actor: withPrincipal, - }), - ), - }, - ); - expect(withdrawalResponse.status).toBe(200); - const withdrawalBody = (await withdrawalResponse.json()) as { - action: string; - nonce: string; - }; - expect(withdrawalBody.action).toBe('3'); - expect(withdrawalBody.nonce).toBe('8'); - - const duplicateNonce = await fetch( - `${harness.baseUrl}/counterparty/signature-request`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - signatureRequestPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - action: '0', - amount: '0', - myBalance: '900', - theirBalance: '125', - nonce: '8', - actor: withPrincipal, - }), - ), - }, - ); - expect(duplicateNonce.status).toBe(409); - const duplicateNonceBody = (await duplicateNonce.json()) as { - reason: string; - }; - expect(duplicateNonceBody.reason).toBe('nonce-too-low'); - - const balanceDecrease = await fetch( - `${harness.baseUrl}/counterparty/signature-request`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify( - signatureRequestPayload({ - forPrincipal: counterpartyPrincipal, - withPrincipal, - action: '0', - amount: '0', - myBalance: '899', - theirBalance: '126', - nonce: '9', - actor: withPrincipal, - }), - ), - }, - ); - expect(balanceDecrease.status).toBe(403); - const balanceDecreaseBody = (await balanceDecrease.json()) as { - reason: string; - }; - expect(balanceDecreaseBody.reason).toBe('counterparty-balance-decrease'); - } finally { - await harness.stop(); - cleanupDbFiles(dbFile); - } - }); - - it('returns 401 when reject-all verifier mode is active', async () => { - const dbFile = path.join( - os.tmpdir(), - `stackflow-watchtower-http-${Date.now()}-${Math.random()}.db`, - ); - const harness = await startHarness({ - dbFile, - extraEnv: { - STACKFLOW_NODE_PRINCIPALS: P1, - STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'reject-all', - STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', - }, - }); - - try { - const response = await fetch(`${harness.baseUrl}/signature-states`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(signatureStatePayload(P1)), - }); - expect(response.status).toBe(401); - } finally { - await harness.stop(); - cleanupDbFiles(dbFile); - } - }); -}); From 43456eca142447d35e38717035e4de711b6e955f Mon Sep 17 00:00:00 2001 From: obycode Date: Mon, 2 Mar 2026 20:17:59 -0500 Subject: [PATCH 34/78] feat: basic x402 gateway with demo --- README.md | 171 +++++ demo/x402-browser/app.js | 794 ++++++++++++++++++++ demo/x402-browser/config.json | 13 + demo/x402-browser/index.html | 89 +++ demo/x402-browser/styles.css | 186 +++++ deployments/default.devnet-plan.yaml | 4 +- package.json | 5 +- scripts/demo-x402-browser.js | 886 ++++++++++++++++++++++ scripts/demo-x402-e2e.js | 514 +++++++++++++ server/X402_GATEWAY_DESIGN.md | 237 ++++++ server/src/x402-gateway.ts | 1024 ++++++++++++++++++++++++++ 11 files changed, 3920 insertions(+), 3 deletions(-) create mode 100644 demo/x402-browser/app.js create mode 100644 demo/x402-browser/config.json create mode 100644 demo/x402-browser/index.html create mode 100644 demo/x402-browser/styles.css create mode 100644 scripts/demo-x402-browser.js create mode 100644 scripts/demo-x402-e2e.js create mode 100644 server/X402_GATEWAY_DESIGN.md create mode 100644 server/src/x402-gateway.ts diff --git a/README.md b/README.md index 14e9291..c1b79d9 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,177 @@ The UI lets you: 5. call Stackflow contract methods (`fund-pipe`, `deposit`, `withdraw`, `force-cancel`, `force-close`, `close-pipe`, `finalize`) via wallet popup +## x402 Gateway Scaffold + +This repo includes a starter x402-style gateway at +`server/src/x402-gateway.ts`. It sits in front of your app server and: + +1. protects one route (`STACKFLOW_X402_PROTECTED_PATH`, default `/paid-content`) +2. returns `402` when no payment proof is provided +3. supports two payment modes: + - `direct`: verifies payment immediately via `POST /counterparty/transfer` + - `indirect`: waits for forwarded payment arrival, checks who paid, then + verifies the provided secret via `POST /forwarding/reveal` +4. proxies the request to your upstream app only after verification succeeds + +Detailed technical design and production guidance: + +- `server/X402_GATEWAY_DESIGN.md` + +Run it with: + +```bash +npm run x402-gateway +``` + +Run a full local end-to-end demo (unpaid + direct + indirect): + +```bash +npm run demo:x402-e2e +``` + +The demo script starts: + +1. stackflow-node (`accept-all` verifier, forwarding enabled) +2. x402 gateway +3. mock upstream app +4. mock forwarding next-hop + +Then it automatically exercises: + +1. unpaid request -> `402` +2. direct payment proof -> payload delivered +3. indirect payment signal (wait for forwarding payment + reveal) -> payload delivered + +Run an interactive browser demo (click link -> `402` -> sign -> unlock): + +```bash +npm run demo:x402-browser +``` + +Browser demo flow: + +1. open the printed local URL (gateway front door) +2. click `Read premium story` +3. gateway returns `402 Payment Required` +4. demo queries `stackflow-node /pipes` for pipe status: + - if no open pipe, prompt `Open Pipe` (wallet `fund-pipe`) first + - if pipe is observer-confirmed and spendable, prompt `Sign and Pay` (`stx_signStructuredMessage`) +5. browser retries with `x-x402-payment` +6. gateway verifies payment with stackflow-node and returns paywalled content + +Notes about the browser demo: + +1. Network/contract selection is loaded from `demo/x402-browser/config.json` + (or `DEMO_X402_CONFIG_FILE`) rather than editable in the browser UI. +2. The demo starts stackflow-node in `accept-all` signature verification mode + for local UX walkthrough. +3. Pipe readiness checks are routed through stackflow-node (`GET /pipes`) via + demo endpoints (`/demo/pipe-status`). +4. `Open Pipe` does not fake state. It waits for real observer updates from + stacks-node into stackflow-node. +5. If the connected wallet is the same principal as the server counterparty, + the demo returns a clear `409` and asks you to switch accounts. +6. Browser-demo network/contract/node settings are defined in + `demo/x402-browser/config.json`. Predefine your stacks-node observer using: + `stacks_node_events_observers = ["host:port"]` matching + `stacksNodeEventsObserver` in that file. +7. Browser-demo starts stackflow-node with `STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE=auto`. + It uses `DEMO_X402_DISPUTE_SIGNER_KEY` if provided. + On `devnet`, if unset, it defaults to Clarinet `wallet_1` fixture key. + On other networks, if unset, it reuses `DEMO_X402_COUNTERPARTY_KEY`. +8. Child process logs are streamed by default (`DEMO_X402_SHOW_CHILD_LOGS=true`). + Set `DEMO_X402_SHOW_CHILD_LOGS=false` to silence stackflow-node/gateway logs. + +Example browser demo config: + +```json +{ + "stacksNetwork": "devnet", + "stacksApiUrl": "http://127.0.0.1:20443", + "contractId": "ST1...stackflow", + "priceAmount": "10", + "priceAsset": "STX", + "openPipeAmount": "1000", + "stackflowNodeHost": "127.0.0.1", + "stackflowNodePort": 8787, + "stacksNodeEventsObserver": "host.docker.internal:8787", + "observerLocalhostOnly": false, + "observerAllowedIps": [] +} +``` + +Gateway environment variables: + +```bash +STACKFLOW_X402_GATEWAY_HOST=127.0.0.1 +STACKFLOW_X402_GATEWAY_PORT=8790 +STACKFLOW_X402_UPSTREAM_BASE_URL=http://127.0.0.1:3000 +STACKFLOW_X402_STACKFLOW_NODE_BASE_URL=http://127.0.0.1:8787 +STACKFLOW_X402_PROTECTED_PATH=/paid-content +STACKFLOW_X402_PRICE_AMOUNT=1000 +STACKFLOW_X402_PRICE_ASSET=STX +STACKFLOW_X402_STACKFLOW_TIMEOUT_MS=10000 +STACKFLOW_X402_UPSTREAM_TIMEOUT_MS=10000 +STACKFLOW_X402_PROOF_REPLAY_TTL_MS=86400000 +STACKFLOW_X402_INDIRECT_WAIT_TIMEOUT_MS=30000 +STACKFLOW_X402_INDIRECT_POLL_INTERVAL_MS=1000 +STACKFLOW_X402_STACKFLOW_ADMIN_READ_TOKEN= +``` + +The client supplies `x-x402-payment` containing JSON (or base64url-encoded JSON) +for one of these modes: + +1. `direct` payment proof (`action = 1`) including: + +- `contractId` +- `forPrincipal` +- `withPrincipal` +- `amount` (must be `>= STACKFLOW_X402_PRICE_AMOUNT`) +- `myBalance` +- `theirBalance` +- `theirSignature` +- `nonce` +- `actor` + +2. `indirect` payment signal including: + +- `mode: "indirect"` +- `paymentId` (forwarding payment id to wait for) +- `secret` (32-byte hex preimage) +- `expectedFromPrincipal` (immediate payer principal expected by receiver) + +Example flow (direct): + +```bash +# 1) Unpaid request gets 402 challenge +curl -i http://127.0.0.1:8790/paid-content + +# 2) Build proof header from a local JSON file +PAYMENT_PROOF=$(node -e "const fs=require('node:fs');const v=JSON.parse(fs.readFileSync('proof.json','utf8'));process.stdout.write(Buffer.from(JSON.stringify(v)).toString('base64url'));") + +# 3) Paid request is verified by stackflow-node then proxied upstream +curl -i \ + -H "x-x402-payment: ${PAYMENT_PROOF}" \ + http://127.0.0.1:8790/paid-content +``` + +Example flow (indirect): + +```bash +INDIRECT_PROOF=$(node -e "const v={mode:'indirect',paymentId:'pay-2026-03-01-0001',secret:'0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',expectedFromPrincipal:'ST2...'};process.stdout.write(Buffer.from(JSON.stringify(v)).toString('base64url'));") + +curl -i \ + -H "x-x402-payment: ${INDIRECT_PROOF}" \ + http://127.0.0.1:8790/paid-content +``` + +Current scaffold scope: + +1. supports direct and indirect receive-side verification +2. one-time proof consumption per method/path within replay TTL +3. single protected route configuration (expand to route policy map as next step) + Integration tests for the HTTP server are opt-in (they spawn a real process and bind a local port): diff --git a/demo/x402-browser/app.js b/demo/x402-browser/app.js new file mode 100644 index 0000000..7881bca --- /dev/null +++ b/demo/x402-browser/app.js @@ -0,0 +1,794 @@ +import { connect, isConnected, request } from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +import { Cl, Pc, principalCV, serializeCV } from "https://esm.sh/@stacks/transactions@7.2.0?bundle&target=es2020"; + +const CHAIN_IDS = { + mainnet: 1n, + testnet: 2147483648n, + devnet: 2147483648n, +}; + +const STORAGE_KEY = "stackflow-x402-browser-settings-v3"; +const STACKFLOW_MESSAGE_VERSION = "0.6.0"; + +const elements = { + premiumLink: document.getElementById("premium-link"), + connectWallet: document.getElementById("connect-wallet"), + walletStatus: document.getElementById("wallet-status"), + logOutput: document.getElementById("log-output"), + responseOutput: document.getElementById("response-output"), + paywallDialog: document.getElementById("paywall-dialog"), + challengeText: document.getElementById("challenge-text"), + payWallet: document.getElementById("pay-wallet"), + openPipe: document.getElementById("open-pipe"), + openAmount: document.getElementById("settings-open-amount"), + configNetwork: document.getElementById("config-network"), + configContract: document.getElementById("config-contract"), + configObserver: document.getElementById("config-observer"), +}; + +const state = { + connectedAddress: null, + lastPaymentChallenge: null, + config: { + network: "testnet", + contractId: "", + counterpartyPrincipal: "", + priceAmount: "", + priceAsset: "", + openPipeAmount: "1000", + stacksNodeEventsObserver: "", + }, +}; + +function normalizedText(value) { + return String(value ?? "").trim(); +} + +function nowStamp() { + return new Date().toISOString().slice(11, 19); +} + +function log(message, { error = false } = {}) { + const line = `[${nowStamp()}] ${message}`; + const current = elements.logOutput.textContent || ""; + elements.logOutput.textContent = `${current}\n${line}`.trimStart(); + elements.logOutput.scrollTop = elements.logOutput.scrollHeight; + if (error) { + console.error(`[x402-demo] ${message}`); + } else { + console.log(`[x402-demo] ${message}`); + } +} + +function setResponseOutput(value) { + if (typeof value === "string") { + elements.responseOutput.textContent = value; + return; + } + elements.responseOutput.textContent = JSON.stringify(value, null, 2); +} + +function setWalletStatus(message, { error = false } = {}) { + elements.walletStatus.textContent = message; + elements.walletStatus.style.color = error ? "#9f1f1f" : "var(--muted)"; +} + +function isStacksAddress(value) { + return typeof value === "string" && /^S[PMT][A-Z0-9]{38,42}$/i.test(value); +} + +function parseContractId(rawInput) { + let contractId = normalizedText(rawInput); + if (!contractId) { + throw new Error("Stackflow contract is required"); + } + if (contractId.startsWith("'")) { + contractId = contractId.slice(1); + } + if (!contractId.includes(".") && contractId.includes("/")) { + contractId = contractId.replace("/", "."); + } + + const parts = contractId.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error("Stackflow contract must be in ADDRESS.NAME form"); + } + + const [address] = parts; + try { + principalCV(address); + } catch { + throw new Error("Invalid contract address"); + } + + return contractId; +} + +function parseNetwork(value) { + const network = normalizedText(value).toLowerCase(); + if (!CHAIN_IDS[network]) { + throw new Error(`Unsupported network: ${network}`); + } + return network; +} + +function parseOpenAmount() { + const openAmountText = normalizedText(elements.openAmount.value); + if (!/^\d+$/.test(openAmountText) || BigInt(openAmountText) <= 0n) { + throw new Error("Open Pipe Amount must be a positive integer"); + } + return BigInt(openAmountText); +} + +function loadStoredOpenAmount() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") { + if (typeof parsed.openAmount === "string" && /^\d+$/.test(parsed.openAmount)) { + elements.openAmount.value = parsed.openAmount; + } + } + } catch { + // ignore malformed storage + } +} + +function persistOpenAmount() { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + openAmount: normalizedText(elements.openAmount.value), + }), + ); +} + +function toHex(bytes) { + return Array.from(bytes) + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +function compareBytes(left, right) { + const len = Math.min(left.length, right.length); + for (let i = 0; i < len; i += 1) { + if (left[i] < right[i]) { + return -1; + } + if (left[i] > right[i]) { + return 1; + } + } + if (left.length < right.length) { + return -1; + } + if (left.length > right.length) { + return 1; + } + return 0; +} + +function canonicalPrincipals(a, b) { + const aBytes = serializeCV(principalCV(a)); + const bBytes = serializeCV(principalCV(b)); + return compareBytes(aBytes, bBytes) <= 0 + ? { principal1: a, principal2: b } + : { principal1: b, principal2: a }; +} + +function makeStxPostConditionForTransfer(principal, amount) { + return Pc.principal(principal).willSendEq(amount).ustx(); +} + +function extractAddress(response) { + const seen = new Set(); + + const findAddress = (value) => { + if (value === null || value === undefined) { + return null; + } + if (isStacksAddress(value)) { + return value; + } + if (typeof value !== "object") { + return null; + } + if (seen.has(value)) { + return null; + } + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + if ( + item && + typeof item === "object" && + String(item.symbol || item.chain || "").toUpperCase().includes("STX") && + isStacksAddress(item.address) + ) { + return item.address; + } + } + for (const item of value) { + const nested = findAddress(item); + if (nested) { + return nested; + } + } + return null; + } + + if (isStacksAddress(value.address)) { + return value.address; + } + if (isStacksAddress(value.stxAddress)) { + return value.stxAddress; + } + if (isStacksAddress(value.stacksAddress)) { + return value.stacksAddress; + } + + const priorityKeys = [ + "result", + "addresses", + "account", + "accounts", + "stx", + "stacks", + "wallet", + ]; + + for (const key of priorityKeys) { + if (key in value) { + const nested = findAddress(value[key]); + if (nested) { + return nested; + } + } + } + + for (const nestedValue of Object.values(value)) { + const nested = findAddress(nestedValue); + if (nested) { + return nested; + } + } + + return null; + }; + + return findAddress(response); +} + +function extractSignature(response) { + if (!response || typeof response !== "object") { + return null; + } + if (typeof response.signature === "string") { + return response.signature; + } + if (response.result && typeof response.result === "object") { + if (typeof response.result.signature === "string") { + return response.result.signature; + } + } + return null; +} + +function extractTxid(response) { + if (!response || typeof response !== "object") { + return null; + } + if (typeof response.txid === "string") { + return response.txid; + } + if (response.result && typeof response.result === "object") { + if (typeof response.result.txid === "string") { + return response.result.txid; + } + } + return null; +} + +function toBase64UrlJson(value) { + const utf8 = new TextEncoder().encode(JSON.stringify(value)); + let binary = ""; + for (const byte of utf8) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function parseJsonResponse(response) { + const rawText = await response.text(); + if (!rawText.trim()) { + return { body: null, rawText }; + } + try { + return { body: JSON.parse(rawText), rawText }; + } catch { + return { body: null, rawText }; + } +} + +function describeFailure(status, payload, rawText, fallback) { + if (payload && typeof payload.error === "string") { + return `${payload.error}${payload.reason ? ` (${payload.reason})` : ""}`; + } + if (rawText && rawText.trim()) { + return rawText.slice(0, 300); + } + return `${fallback} (status ${status})`; +} + +function updateDialogMessage(text) { + elements.challengeText.textContent = text; +} + +function setDialogActions({ allowOpenPipe, allowPay }) { + elements.openPipe.disabled = !allowOpenPipe; + elements.payWallet.disabled = !allowPay; +} + +async function withPaywallDialogSuspended(work) { + const wasOpen = elements.paywallDialog.open; + if (wasOpen) { + elements.paywallDialog.close("wallet-ui"); + } + try { + return await work(); + } finally { + if (wasOpen && state.lastPaymentChallenge) { + elements.paywallDialog.showModal(); + await updatePaywallReadiness(); + } + } +} + +function renderConnectedState() { + if (state.connectedAddress) { + setWalletStatus(`Connected: ${state.connectedAddress}`); + elements.connectWallet.textContent = "Reconnect Wallet"; + } else { + setWalletStatus("Wallet not connected"); + elements.connectWallet.textContent = "Connect Wallet"; + } +} + +function renderConfigState() { + elements.configNetwork.textContent = state.config.network || "-"; + elements.configContract.textContent = state.config.contractId || "-"; + elements.configObserver.textContent = state.config.stacksNodeEventsObserver || "-"; +} + +async function resolveConnectedAddress(connectResponse = null) { + const initialAddress = extractAddress(connectResponse); + if (initialAddress) { + return initialAddress; + } + const response = await request("getAddresses"); + const address = extractAddress(response); + if (!address) { + throw new Error("Wallet connected, but no valid STX address was found"); + } + return address; +} + +async function ensureConnectedWallet({ interactive }) { + if (state.connectedAddress) { + return state.connectedAddress; + } + + let connected = false; + try { + connected = await Promise.resolve(isConnected()); + } catch { + connected = false; + } + if (connected) { + const address = await resolveConnectedAddress(null); + state.connectedAddress = address; + renderConnectedState(); + return address; + } + + if (!interactive) { + return null; + } + + const response = await withPaywallDialogSuspended(() => connect()); + const address = await resolveConnectedAddress(response); + state.connectedAddress = address; + renderConnectedState(); + return address; +} + +async function fetchDemoConfig() { + const response = await fetch("/demo/config"); + const { body, rawText } = await parseJsonResponse(response); + if (!response.ok || !body || typeof body !== "object") { + throw new Error(describeFailure(response.status, body, rawText, "Failed to load demo config")); + } + + const network = parseNetwork(body.network); + const contractId = parseContractId(body.contractId); + const counterpartyPrincipal = normalizedText(body.counterpartyPrincipal); + if (!isStacksAddress(counterpartyPrincipal)) { + throw new Error("Demo config did not include a valid counterparty principal"); + } + + state.config.network = network; + state.config.contractId = contractId; + state.config.counterpartyPrincipal = counterpartyPrincipal; + state.config.priceAmount = normalizedText(body.priceAmount); + state.config.priceAsset = normalizedText(body.priceAsset); + state.config.openPipeAmount = /^\d+$/.test(normalizedText(body.openPipeAmount)) + ? normalizedText(body.openPipeAmount) + : "1000"; + state.config.stacksNodeEventsObserver = normalizedText(body.stacksNodeEventsObserver); + + if (!normalizedText(elements.openAmount.value)) { + elements.openAmount.value = state.config.openPipeAmount; + } + + renderConfigState(); +} + +async function fetchPipeStatus() { + if (!state.connectedAddress) { + throw new Error("Connect wallet first"); + } + + const response = await fetch("/demo/pipe-status", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ principal: state.connectedAddress }), + }); + + const { body, rawText } = await parseJsonResponse(response); + if (!response.ok || !body || typeof body !== "object") { + throw new Error(describeFailure(response.status, body, rawText, "Failed to fetch pipe status")); + } + + return { + hasPipe: Boolean(body.hasPipe), + canPay: Boolean(body.canPay), + myConfirmed: normalizedText(body.myConfirmed || "0"), + myPending: normalizedText(body.myPending || "0"), + theirConfirmed: normalizedText(body.theirConfirmed || "0"), + theirPending: normalizedText(body.theirPending || "0"), + nonce: normalizedText(body.nonce || "0"), + source: normalizedText(body.source || ""), + }; +} + +async function updatePaywallReadiness() { + if (!state.lastPaymentChallenge) { + return; + } + + if (!state.connectedAddress) { + updateDialogMessage("Connect wallet, then check pipe status. If no pipe exists, open one first."); + setDialogActions({ allowOpenPipe: true, allowPay: true }); + return; + } + + if (!state.config.counterpartyPrincipal) { + updateDialogMessage("Counterparty principal is not available from the demo server."); + setDialogActions({ allowOpenPipe: false, allowPay: false }); + return; + } + + try { + const pipe = await fetchPipeStatus(); + if (!pipe.hasPipe) { + updateDialogMessage( + "No open pipe found in stackflow-node for this account/counterparty. Open a pipe first, then sign and pay.", + ); + setDialogActions({ allowOpenPipe: true, allowPay: false }); + return; + } + + if (pipe.canPay) { + updateDialogMessage( + `Pipe ready via stackflow-node (my confirmed=${pipe.myConfirmed}, pending=${pipe.myPending}, source=${pipe.source || "unknown"}). Sign and pay to continue.`, + ); + setDialogActions({ allowOpenPipe: true, allowPay: true }); + return; + } + + updateDialogMessage( + `Pipe observed but not spendable yet (my confirmed=${pipe.myConfirmed}, pending=${pipe.myPending}). Wait for confirmation from stacks-node observer, then sign and pay.`, + ); + setDialogActions({ allowOpenPipe: true, allowPay: false }); + } catch (error) { + updateDialogMessage( + `Unable to check stackflow-node pipe status: ${error instanceof Error ? error.message : String(error)}`, + ); + setDialogActions({ allowOpenPipe: true, allowPay: false }); + } +} + +function buildPaymentProofPayload(intent, signature) { + return { + ...intent, + theirSignature: signature, + }; +} + +function buildStructuredMessage(intent) { + const pair = canonicalPrincipals(intent.forPrincipal, intent.withPrincipal); + const balance1 = + pair.principal1 === intent.forPrincipal + ? BigInt(intent.myBalance) + : BigInt(intent.theirBalance); + const balance2 = + pair.principal1 === intent.forPrincipal + ? BigInt(intent.theirBalance) + : BigInt(intent.myBalance); + + const domain = Cl.tuple({ + name: Cl.stringAscii(intent.contractId), + version: Cl.stringAscii(STACKFLOW_MESSAGE_VERSION), + "chain-id": Cl.uint(CHAIN_IDS[state.config.network] || CHAIN_IDS.testnet), + }); + + const message = Cl.tuple({ + token: Cl.none(), + "principal-1": Cl.principal(pair.principal1), + "principal-2": Cl.principal(pair.principal2), + "balance-1": Cl.uint(balance1), + "balance-2": Cl.uint(balance2), + nonce: Cl.uint(BigInt(intent.nonce)), + action: Cl.uint(BigInt(intent.action)), + actor: Cl.principal(intent.actor), + "hashed-secret": Cl.none(), + "valid-after": Cl.none(), + }); + + return { domain, message }; +} + +async function createPaymentIntent() { + const response = await fetch("/demo/payment-intent", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + withPrincipal: state.connectedAddress, + }), + }); + + const { body, rawText } = await parseJsonResponse(response); + if (!response.ok || !body || typeof body !== "object") { + throw new Error( + describeFailure(response.status, body, rawText, "Failed to create payment intent"), + ); + } + + if (!body.intent || typeof body.intent !== "object") { + throw new Error("Payment intent response missing intent"); + } + + return body.intent; +} + +async function fetchPaywalledStory(paymentProof = null) { + const headers = {}; + if (paymentProof) { + headers["x-x402-payment"] = toBase64UrlJson(paymentProof); + } + + const response = await fetch("/paywalled-story", { + method: "GET", + headers, + }); + + const { body, rawText } = await parseJsonResponse(response); + + if (response.status === 402) { + state.lastPaymentChallenge = body && typeof body === "object" ? body : {}; + const payment = body && typeof body === "object" ? body.payment : null; + const amount = payment && typeof payment === "object" ? payment.amount : "?"; + const asset = payment && typeof payment === "object" ? payment.asset : "?"; + log(`Received 402 challenge: ${amount} ${asset} required.`); + setResponseOutput(body || rawText || "Payment required"); + elements.paywallDialog.showModal(); + await updatePaywallReadiness(); + return; + } + + if (!response.ok) { + const details = describeFailure(response.status, body, rawText, "Request failed"); + throw new Error(details); + } + + state.lastPaymentChallenge = null; + setResponseOutput(body || rawText || "OK"); + log("Unlocked paywalled content."); +} + +async function onConnectWallet() { + try { + await ensureConnectedWallet({ interactive: true }); + log(`Wallet connected: ${state.connectedAddress}`); + await updatePaywallReadiness(); + } catch (error) { + setWalletStatus( + error instanceof Error ? error.message : "wallet connection failed", + { error: true }, + ); + log( + `Wallet connection failed: ${error instanceof Error ? error.message : String(error)}`, + { error: true }, + ); + } +} + +async function onOpenPipe() { + try { + await ensureConnectedWallet({ interactive: true }); + if (!state.config.counterpartyPrincipal) { + throw new Error("counterparty principal unavailable"); + } + + const openAmount = parseOpenAmount(); + const pipeStatus = await fetchPipeStatus(); + const nonce = /^\d+$/.test(pipeStatus.nonce) ? BigInt(pipeStatus.nonce) : 0n; + + const response = await withPaywallDialogSuspended(() => + request("stx_callContract", { + contract: state.config.contractId, + functionName: "fund-pipe", + functionArgs: [ + Cl.none(), + Cl.uint(openAmount), + Cl.principal(state.config.counterpartyPrincipal), + Cl.uint(nonce), + ], + postConditions: [ + makeStxPostConditionForTransfer(state.connectedAddress, openAmount), + ], + postConditionMode: "deny", + network: state.config.network, + }), + ); + + const txid = extractTxid(response); + log( + txid + ? `fund-pipe submitted: ${txid}` + : "fund-pipe submitted (wallet response received).", + ); + updateDialogMessage( + "fund-pipe submitted. Waiting for stacks-node observer events to reach stackflow-node...", + ); + setDialogActions({ allowOpenPipe: true, allowPay: false }); + + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + const nextStatus = await fetchPipeStatus(); + if (nextStatus.canPay) { + log("Spendable pipe detected via stackflow-node observer state."); + await updatePaywallReadiness(); + return; + } + } + + log( + "Pipe is still not spendable in stackflow-node. Verify stacks-node observer config and wait for confirmations.", + { error: true }, + ); + await updatePaywallReadiness(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Open pipe failed: ${message}`, { error: true }); + updateDialogMessage(`Open pipe failed: ${message}`); + } +} + +async function onSignAndPay() { + try { + await ensureConnectedWallet({ interactive: true }); + + const pipe = await fetchPipeStatus(); + if (!pipe.hasPipe || !pipe.canPay) { + updateDialogMessage( + "Pipe is not spendable in stackflow-node yet. Open a pipe and wait for observer confirmation before signing.", + ); + setDialogActions({ allowOpenPipe: true, allowPay: false }); + return; + } + + const intent = await createPaymentIntent(); + const statePayload = buildStructuredMessage(intent); + const signResponse = await withPaywallDialogSuspended(() => + request("stx_signStructuredMessage", { + domain: statePayload.domain, + message: statePayload.message, + }), + ); + + const signature = extractSignature(signResponse); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } + + const proof = buildPaymentProofPayload(intent, signature); + await fetchPaywalledStory(proof); + if (elements.paywallDialog.open) { + elements.paywallDialog.close("paid"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Sign and pay failed: ${message}`, { error: true }); + updateDialogMessage(`Sign and pay failed: ${message}`); + } +} + +async function onPremiumLinkClick(event) { + event.preventDefault(); + try { + setResponseOutput("Requesting protected resource..."); + await fetchPaywalledStory(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setResponseOutput(`Error: ${message}`); + log(`Failed to fetch paywalled story: ${message}`, { error: true }); + } +} + +function wireEvents() { + elements.premiumLink.addEventListener("click", onPremiumLinkClick); + elements.connectWallet.addEventListener("click", onConnectWallet); + elements.openPipe.addEventListener("click", onOpenPipe); + elements.payWallet.addEventListener("click", onSignAndPay); + elements.openAmount.addEventListener("change", () => { + persistOpenAmount(); + }); +} + +async function bootstrap() { + wireEvents(); + loadStoredOpenAmount(); + + try { + await fetchDemoConfig(); + if (!normalizedText(elements.openAmount.value)) { + elements.openAmount.value = state.config.openPipeAmount; + } + persistOpenAmount(); + log( + `Demo config loaded: network=${state.config.network} contract=${state.config.contractId}`, + ); + } catch (error) { + log( + `Failed to load demo config: ${error instanceof Error ? error.message : String(error)}`, + { error: true }, + ); + } + + try { + await ensureConnectedWallet({ interactive: false }); + } catch (error) { + log( + `Wallet session restore failed: ${error instanceof Error ? error.message : String(error)}`, + { error: true }, + ); + } + + renderConnectedState(); + setDialogActions({ allowOpenPipe: true, allowPay: false }); + updateDialogMessage("Click the premium link to trigger the x402 challenge."); + + log("Ready."); +} + +bootstrap().catch((error) => { + log(`Fatal startup error: ${error instanceof Error ? error.message : String(error)}`, { + error: true, + }); +}); diff --git a/demo/x402-browser/config.json b/demo/x402-browser/config.json new file mode 100644 index 0000000..9c3344e --- /dev/null +++ b/demo/x402-browser/config.json @@ -0,0 +1,13 @@ +{ + "stacksNetwork": "devnet", + "stacksApiUrl": "http://127.0.0.1:20443", + "contractId": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow", + "priceAmount": "10", + "priceAsset": "STX", + "openPipeAmount": "1000", + "stackflowNodeHost": "127.0.0.1", + "stackflowNodePort": 8787, + "stacksNodeEventsObserver": "host.docker.internal:8787", + "observerLocalhostOnly": false, + "observerAllowedIps": [] +} diff --git a/demo/x402-browser/index.html b/demo/x402-browser/index.html new file mode 100644 index 0000000..0dddff5 --- /dev/null +++ b/demo/x402-browser/index.html @@ -0,0 +1,89 @@ + + + + + + Stackflow x402 Browser Demo + + + +
+
+

Stackflow x402 Demo

+

Click Through a Browser Paywall

+

+ This demo simulates a real user flow: visit a page, hit a 402 challenge, + sign the payment, then unlock the premium content. +

+
+ +
+

Node Config

+
+

+ Network + - +

+

+ Contract + - +

+

+ Observer + - +

+ +
+
+ +
+

Public Preview

+

+ Read this teaser and then open the paywalled story. +

+ Read premium story +
+ +
+

Wallet

+

Wallet not connected

+ +
+ +
+

Flow Log

+
Ready.
+
+ +
+

Response

+
No paywalled response yet.
+
+
+ + +
+

Payment Required

+

+ The server requires payment before this resource can be accessed. +

+ + + + + +
+
+ + + + diff --git a/demo/x402-browser/styles.css b/demo/x402-browser/styles.css new file mode 100644 index 0000000..64a9eb8 --- /dev/null +++ b/demo/x402-browser/styles.css @@ -0,0 +1,186 @@ +:root { + color-scheme: light; + --bg: #f2f5eb; + --ink: #14231c; + --muted: #4f6358; + --line: #c7d3c0; + --card: #ffffff; + --accent: #0b7f4a; + --accent-ink: #ffffff; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: radial-gradient(circle at top right, #e4ecdc 0%, var(--bg) 45%); + color: var(--ink); + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; +} + +.page { + max-width: 900px; + margin: 0 auto; + padding: 2rem 1rem 3rem; + display: grid; + gap: 1rem; +} + +.hero { + background: linear-gradient(120deg, #183f2f 0%, #23563d 100%); + color: #f4f8f1; + border-radius: 1rem; + padding: 1.2rem 1.2rem 1.4rem; +} + +.eyebrow { + margin: 0 0 0.5rem; + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.9; +} + +.hero h1 { + margin: 0; + font-size: 1.6rem; +} + +.sub { + margin: 0.7rem 0 0; + max-width: 70ch; + line-height: 1.45; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; +} + +.card h2 { + margin: 0 0 0.6rem; + font-size: 1.1rem; +} + +.field-grid { + display: grid; + gap: 0.7rem; +} + +.field-grid label { + display: grid; + gap: 0.35rem; + font-size: 0.9rem; + color: var(--muted); +} + +.config-line { + margin: 0; + display: flex; + gap: 0.6rem; + align-items: baseline; +} + +.config-key { + color: var(--muted); + min-width: 5rem; +} + +.config-value { + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 0.86rem; + overflow-wrap: anywhere; +} + +.field-grid input, +.field-grid select { + border: 1px solid var(--line); + border-radius: 0.5rem; + padding: 0.5rem 0.6rem; + font: inherit; + color: var(--ink); + background: #fff; +} + +.cta, +button { + display: inline-block; + border: 0; + border-radius: 0.6rem; + padding: 0.55rem 0.8rem; + background: var(--accent); + color: var(--accent-ink); + text-decoration: none; + font: inherit; + cursor: pointer; +} + +.cta:hover, +button:hover { + filter: brightness(1.05); +} + +.log, +.response { + margin: 0; + background: #14231c; + color: #d6f9d1; + padding: 0.8rem; + border-radius: 0.6rem; + overflow: auto; + min-height: 6rem; + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 0.88rem; + line-height: 1.35; +} + +.response { + min-height: 10rem; +} + +#wallet-status { + margin: 0 0 0.75rem; + color: var(--muted); +} + +dialog { + border: 1px solid var(--line); + border-radius: 0.8rem; + max-width: 420px; + width: calc(100% - 2rem); +} + +.dialog-body { + display: grid; + gap: 0.8rem; +} + +.dialog-body h3 { + margin: 0; +} + +.dialog-body p { + margin: 0; + color: var(--muted); +} + +.dialog-actions { + margin: 0; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0; +} + +#open-pipe { + background: #2264a2; +} + +.dialog-actions button[value="cancel"] { + background: #becbbf; + color: #1e3026; +} diff --git a/deployments/default.devnet-plan.yaml b/deployments/default.devnet-plan.yaml index 4ac995a..9e7e036 100644 --- a/deployments/default.devnet-plan.yaml +++ b/deployments/default.devnet-plan.yaml @@ -13,7 +13,7 @@ plan: remap-principals: SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM cost: 8400 - path: ./.cache/requirements/SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.clar + path: .cache/requirements/SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.clar clarity-version: 1 epoch: '2.0' - id: 1 @@ -28,7 +28,7 @@ plan: - transaction-type: contract-publish contract-name: reservoir expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - cost: 187420 + cost: 187470 path: contracts/reservoir.clar anchor-block-only: true clarity-version: 4 diff --git a/package.json b/package.json index 6ca99ab..225389f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"", "build:stackflow-node": "tsc -p tsconfig.server.json", - "stackflow-node": "npm run build:stackflow-node && node server/dist/index.js" + "stackflow-node": "npm run build:stackflow-node && node server/dist/index.js", + "x402-gateway": "npm run build:stackflow-node && node server/dist/x402-gateway.js", + "demo:x402-e2e": "node scripts/demo-x402-e2e.js", + "demo:x402-browser": "node scripts/demo-x402-browser.js" }, "author": "", "license": "ISC", diff --git a/scripts/demo-x402-browser.js b/scripts/demo-x402-browser.js new file mode 100644 index 0000000..b35e187 --- /dev/null +++ b/scripts/demo-x402-browser.js @@ -0,0 +1,886 @@ +import { execFileSync, spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { once } from "node:events"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const ROOT = process.cwd(); +const STACKFLOW_ENTRY = path.join(ROOT, "server", "dist", "index.js"); +const GATEWAY_ENTRY = path.join(ROOT, "server", "dist", "x402-gateway.js"); +const WEB_ROOT = path.join(ROOT, "demo", "x402-browser"); +const DEFAULT_CONFIG_PATH = path.join(WEB_ROOT, "config.json"); +const DEFAULT_DEVNET_DISPUTE_SIGNER_KEY = + "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801"; + +function buildRandomCounterpartyKey() { + // 32-byte secp256k1 private key + compressed-pubkey marker (01), matching + // the format used by STACKFLOW_NODE_COUNTERPARTY_KEY in this repo. + return `0x${randomBytes(32).toString("hex")}01`; +} + +function selectDisputeSignerKey({ network, counterpartySignerKey }) { + const explicit = process.env.DEMO_X402_DISPUTE_SIGNER_KEY?.trim(); + if (explicit) { + return { + disputeSignerKey: explicit, + source: "env", + }; + } + if (network === "devnet") { + return { + disputeSignerKey: DEFAULT_DEVNET_DISPUTE_SIGNER_KEY, + source: "clarinet-devnet-default", + }; + } + return { + disputeSignerKey: counterpartySignerKey, + source: "counterparty-fallback", + }; +} + +function cleanupDbFiles(dbFile) { + for (const suffix of ["", "-wal", "-shm"]) { + const file = `${dbFile}${suffix}`; + if (fs.existsSync(file)) { + fs.unlinkSync(file); + } + } +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("failed to allocate free port")); + return; + } + const port = address.port; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + server.on("error", reject); + }); +} + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isStacksPrincipal(value) { + return typeof value === "string" && /^S[PMT][A-Z0-9]{38,42}$/i.test(value); +} + +function isContractId(value) { + if (typeof value !== "string") { + return false; + } + const trimmed = value.trim(); + const parts = trimmed.split("."); + if (parts.length !== 2) { + return false; + } + const [address, name] = parts; + return isStacksPrincipal(address) && /^[a-zA-Z][a-zA-Z0-9-]{0,127}$/.test(name); +} + +function parseUintString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) { + throw new Error(`${fieldName} must be an unsigned integer string`); + } + return text; +} + +function parseHost(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be a non-empty host`); + } + return text; +} + +function parsePort(value, fieldName) { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) { + throw new Error(`${fieldName} must be an integer between 1 and 65535`); + } + const parsed = Number.parseInt(text, 10); + if (parsed < 1 || parsed > 65535) { + throw new Error(`${fieldName} must be an integer between 1 and 65535`); + } + return parsed; +} + +function parseNetwork(value) { + const network = String(value ?? "").trim().toLowerCase(); + if (network === "devnet" || network === "testnet" || network === "mainnet") { + return network; + } + throw new Error("stacksNetwork must be one of devnet, testnet, mainnet"); +} + +function parseBoolean(value, fallback) { + if (value === undefined || value === null || value === "") { + return fallback; + } + const normalized = String(value).trim().toLowerCase(); + if (["1", "true", "yes", "y", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "n", "off"].includes(normalized)) { + return false; + } + return fallback; +} + +function parseCsv(value) { + if (value === undefined || value === null || value === "") { + return []; + } + return String(value) + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseOptionalHttpUrl(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + return null; + } + + let parsed; + try { + parsed = new URL(text); + } catch { + throw new Error(`${fieldName} must be a valid URL`); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`${fieldName} must use http/https`); + } + + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + const normalized = parsed.toString(); + return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; +} + +function parseObserverAddress(value) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error("stacksNodeEventsObserver must be formatted as host:port"); + } + const parts = text.split(":"); + if (parts.length !== 2 || !parts[0] || !/^\d+$/.test(parts[1])) { + throw new Error("stacksNodeEventsObserver must be formatted as host:port"); + } + const port = Number.parseInt(parts[1], 10); + if (port < 1 || port > 65535) { + throw new Error("stacksNodeEventsObserver port must be between 1 and 65535"); + } + return text; +} + +function parseAssetName(value) { + const text = String(value ?? "").trim(); + if (!text || text.length > 20) { + throw new Error("priceAsset must be a short non-empty string"); + } + return text; +} + +function loadDemoConfig() { + const configPath = process.env.DEMO_X402_CONFIG_FILE?.trim() || DEFAULT_CONFIG_PATH; + const raw = fs.readFileSync(configPath, "utf8"); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error( + `failed to parse demo config JSON at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!isRecord(parsed)) { + throw new Error(`demo config at ${configPath} must be a JSON object`); + } + + const config = { + configPath, + stacksNetwork: parseNetwork(parsed.stacksNetwork), + stacksApiUrl: parseOptionalHttpUrl(parsed.stacksApiUrl, "stacksApiUrl"), + contractId: String(parsed.contractId || "").trim(), + priceAmount: parseUintString(parsed.priceAmount, "priceAmount"), + priceAsset: parseAssetName(parsed.priceAsset), + openPipeAmount: parseUintString(parsed.openPipeAmount, "openPipeAmount"), + stackflowNodeHost: parseHost(parsed.stackflowNodeHost, "stackflowNodeHost"), + stackflowNodePort: parsePort(parsed.stackflowNodePort, "stackflowNodePort"), + stacksNodeEventsObserver: parseObserverAddress(parsed.stacksNodeEventsObserver), + observerLocalhostOnly: parseBoolean(parsed.observerLocalhostOnly, true), + observerAllowedIps: parseCsv(parsed.observerAllowedIps), + }; + + if (!isContractId(config.contractId)) { + throw new Error("contractId must be a valid contract principal"); + } + + return config; +} + +function extractCounterpartyPrincipal(healthBody) { + if (!isRecord(healthBody)) { + return null; + } + return typeof healthBody.counterpartyPrincipal === "string" + ? healthBody.counterpartyPrincipal + : null; +} + +function parseJsonBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + request.on("data", (chunk) => { + chunks.push(chunk); + if (Buffer.concat(chunks).length > 1024 * 1024) { + reject(new Error("request body too large")); + } + }); + request.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolve({}); + return; + } + resolve(JSON.parse(raw)); + } catch (error) { + reject(error); + } + }); + request.on("error", reject); + }); +} + +function json(response, statusCode, payload) { + response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(payload)); +} + +function text(response, statusCode, body, contentType) { + response.writeHead(statusCode, { "content-type": contentType }); + response.end(body); +} + +function parseUnsignedBigInt(value) { + if (typeof value !== "string" || !/^\d+$/.test(value)) { + return null; + } + try { + return BigInt(value); + } catch { + return null; + } +} + +function selectBestPipeFromNode({ + pipes, + principal, + counterpartyPrincipal, + contractId, +}) { + if (!Array.isArray(pipes)) { + return null; + } + + let best = null; + let bestNonce = -1n; + let bestUpdatedAt = ""; + + for (const candidate of pipes) { + if (!isRecord(candidate)) { + continue; + } + if (candidate.contractId !== contractId) { + continue; + } + + const pipeKey = isRecord(candidate.pipeKey) ? candidate.pipeKey : null; + if (!pipeKey) { + continue; + } + + const principal1 = typeof pipeKey["principal-1"] === "string" ? pipeKey["principal-1"] : null; + const principal2 = typeof pipeKey["principal-2"] === "string" ? pipeKey["principal-2"] : null; + if (!principal1 || !principal2) { + continue; + } + + const samePair = + (principal1 === principal && principal2 === counterpartyPrincipal) || + (principal1 === counterpartyPrincipal && principal2 === principal); + if (!samePair) { + continue; + } + + const nonce = parseUnsignedBigInt(typeof candidate.nonce === "string" ? candidate.nonce : "0") ?? 0n; + const updatedAt = typeof candidate.updatedAt === "string" ? candidate.updatedAt : ""; + + if (!best || nonce > bestNonce || (nonce === bestNonce && updatedAt > bestUpdatedAt)) { + best = candidate; + bestNonce = nonce; + bestUpdatedAt = updatedAt; + } + } + + if (!best) { + return null; + } + + const pipeKey = best.pipeKey; + const principal1 = String(pipeKey["principal-1"]); + const useBalance1 = principal1 === principal; + + const balance1 = parseUnsignedBigInt(String(best.balance1 ?? "0")) ?? 0n; + const balance2 = parseUnsignedBigInt(String(best.balance2 ?? "0")) ?? 0n; + const pending1 = parseUnsignedBigInt(String(best.pending1Amount ?? "0")) ?? 0n; + const pending2 = parseUnsignedBigInt(String(best.pending2Amount ?? "0")) ?? 0n; + + const myConfirmed = useBalance1 ? balance1 : balance2; + const myPending = useBalance1 ? pending1 : pending2; + const theirConfirmed = useBalance1 ? balance2 : balance1; + const theirPending = useBalance1 ? pending2 : pending1; + + return { + hasPipe: true, + canPay: myConfirmed > 0n, + myConfirmed, + myPending, + theirConfirmed, + theirPending, + nonce: parseUnsignedBigInt(String(best.nonce ?? "0")) ?? 0n, + source: typeof best.source === "string" ? best.source : null, + event: typeof best.event === "string" ? best.event : null, + updatedAt: typeof best.updatedAt === "string" ? best.updatedAt : null, + principal1, + principal2: String(pipeKey["principal-2"]), + balance1, + balance2, + }; +} + +function toPipeStatusJson(pipe) { + if (!pipe) { + return { + hasPipe: false, + canPay: false, + myConfirmed: "0", + myPending: "0", + theirConfirmed: "0", + theirPending: "0", + nonce: "0", + source: null, + event: null, + updatedAt: null, + }; + } + + return { + hasPipe: pipe.hasPipe, + canPay: pipe.canPay, + myConfirmed: pipe.myConfirmed.toString(10), + myPending: pipe.myPending.toString(10), + theirConfirmed: pipe.theirConfirmed.toString(10), + theirPending: pipe.theirPending.toString(10), + nonce: pipe.nonce.toString(10), + source: pipe.source, + event: pipe.event, + updatedAt: pipe.updatedAt, + }; +} + +async function waitForHealth(baseUrl, label, child, logsRef) { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`${label} exited before health check.\n${logsRef.join("")}`); + } + try { + const response = await fetch(`${baseUrl}/health`); + if (response.status === 200) { + return; + } + } catch { + // retry + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${label} health timeout.\n${logsRef.join("")}`); +} + +async function startChildProcess({ label, entry, env, baseUrl, streamLogs = true }) { + const logsRef = []; + const child = spawn("node", [entry], { + cwd: ROOT, + env: { + ...process.env, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + const text = chunk.toString("utf8"); + logsRef.push(text); + if (streamLogs) { + process.stdout.write(`[${label}] ${text}`); + } + }); + child.stderr.on("data", (chunk) => { + const text = chunk.toString("utf8"); + logsRef.push(text); + if (streamLogs) { + process.stderr.write(`[${label}] ${text}`); + } + }); + + await waitForHealth(baseUrl, label, child, logsRef); + + return { + logs: logsRef, + stop: async () => { + if (child.exitCode !== null) { + return; + } + child.kill("SIGTERM"); + await once(child, "exit"); + }, + }; +} + +function loadWebAssets() { + return { + indexHtml: fs.readFileSync(path.join(WEB_ROOT, "index.html"), "utf8"), + appJs: fs.readFileSync(path.join(WEB_ROOT, "app.js"), "utf8"), + stylesCss: fs.readFileSync(path.join(WEB_ROOT, "styles.css"), "utf8"), + }; +} + +function createDemoSiteServer({ + port, + stackflowBaseUrl, + counterpartyPrincipal, + demoConfig, +}) { + const assets = loadWebAssets(); + + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url || "/", "http://localhost"); + + if (request.method === "GET" && url.pathname === "/health") { + json(response, 200, { + ok: true, + service: "x402-browser-demo-site", + }); + return; + } + + if (request.method === "GET" && url.pathname === "/") { + text(response, 200, assets.indexHtml, "text/html; charset=utf-8"); + return; + } + + if (request.method === "GET" && url.pathname === "/app.js") { + text(response, 200, assets.appJs, "application/javascript; charset=utf-8"); + return; + } + + if (request.method === "GET" && url.pathname === "/styles.css") { + text(response, 200, assets.stylesCss, "text/css; charset=utf-8"); + return; + } + + if (request.method === "GET" && url.pathname === "/demo/config") { + json(response, 200, { + ok: true, + network: demoConfig.stacksNetwork, + contractId: demoConfig.contractId, + counterpartyPrincipal, + priceAmount: demoConfig.priceAmount, + priceAsset: demoConfig.priceAsset, + openPipeAmount: demoConfig.openPipeAmount, + stacksNodeEventsObserver: demoConfig.stacksNodeEventsObserver, + }); + return; + } + + if (request.method === "POST" && url.pathname === "/demo/pipe-status") { + const body = await parseJsonBody(request); + const principal = isRecord(body) ? String(body.principal || "").trim() : ""; + if (!isStacksPrincipal(principal)) { + json(response, 400, { + ok: false, + error: "principal must be a valid STX address", + }); + return; + } + + const pipesResponse = await fetch( + `${stackflowBaseUrl}/pipes?principal=${encodeURIComponent(principal)}&limit=200`, + ); + const pipesBody = await pipesResponse.json().catch(() => null); + + if (pipesResponse.status !== 200 || !isRecord(pipesBody)) { + json(response, 502, { + ok: false, + error: "failed to query stackflow-node pipes", + }); + return; + } + + const selectedPipe = selectBestPipeFromNode({ + pipes: pipesBody.pipes, + principal, + counterpartyPrincipal, + contractId: demoConfig.contractId, + }); + + json(response, 200, { + ok: true, + principal, + counterpartyPrincipal, + contractId: demoConfig.contractId, + ...toPipeStatusJson(selectedPipe), + }); + return; + } + + if (request.method === "GET" && url.pathname === "/paywalled-story") { + json(response, 200, { + ok: true, + title: "The Paywalled Story", + body: "You unlocked this page via x402 payment flow.", + verifiedByGateway: request.headers["x-stackflow-x402-verified"] === "true", + proofHash: + typeof request.headers["x-stackflow-x402-proof-hash"] === "string" + ? request.headers["x-stackflow-x402-proof-hash"] + : null, + }); + return; + } + + if (request.method === "POST" && url.pathname === "/demo/payment-intent") { + const body = await parseJsonBody(request); + const withPrincipal = isRecord(body) ? String(body.withPrincipal || "").trim() : ""; + + if (!isStacksPrincipal(withPrincipal)) { + json(response, 400, { + ok: false, + error: "withPrincipal must be a valid STX address", + }); + return; + } + + if (withPrincipal === counterpartyPrincipal) { + json(response, 409, { + ok: false, + error: + "connected wallet matches the server counterparty principal; use a different wallet account", + reason: "payer-matches-counterparty", + }); + return; + } + + const pipesResponse = await fetch( + `${stackflowBaseUrl}/pipes?principal=${encodeURIComponent(withPrincipal)}&limit=200`, + ); + const pipesBody = await pipesResponse.json().catch(() => null); + if (pipesResponse.status !== 200 || !isRecord(pipesBody)) { + json(response, 502, { + ok: false, + error: "failed to query stackflow-node pipes", + reason: "pipes-query-failed", + }); + return; + } + + const pipe = selectBestPipeFromNode({ + pipes: pipesBody.pipes, + principal: withPrincipal, + counterpartyPrincipal, + contractId: demoConfig.contractId, + }); + if (!pipe || !pipe.hasPipe) { + json(response, 409, { + ok: false, + error: "no pipe found between connected wallet and counterparty", + reason: "pipe-not-found", + }); + return; + } + if (!pipe.canPay) { + json(response, 409, { + ok: false, + error: "pipe exists but payer has no confirmed balance available yet", + reason: "pipe-not-ready", + myConfirmed: pipe.myConfirmed.toString(10), + myPending: pipe.myPending.toString(10), + }); + return; + } + + const priceAmount = BigInt(demoConfig.priceAmount); + if (pipe.myConfirmed < priceAmount) { + json(response, 409, { + ok: false, + error: "insufficient confirmed pipe balance for payment", + reason: "insufficient-pipe-balance", + payerBalance: pipe.myConfirmed.toString(10), + requiredAmount: priceAmount.toString(10), + }); + return; + } + + const counterpartyBalance = pipe.principal1 === counterpartyPrincipal + ? pipe.balance1 + : pipe.balance2; + const totalBalance = pipe.balance1 + pipe.balance2; + const nextNonce = pipe.nonce + 1n; + const nextMyBalance = counterpartyBalance + priceAmount; + const nextTheirBalance = totalBalance - nextMyBalance; + + json(response, 200, { + ok: true, + intent: { + contractId: demoConfig.contractId, + forPrincipal: counterpartyPrincipal, + withPrincipal, + token: null, + amount: priceAmount.toString(10), + myBalance: nextMyBalance.toString(10), + theirBalance: nextTheirBalance.toString(10), + nonce: nextNonce.toString(10), + action: "1", + actor: withPrincipal, + hashedSecret: null, + validAfter: null, + beneficialOnly: false, + }, + }); + return; + } + + json(response, 404, { ok: false, error: "not found" }); + } catch (error) { + json(response, 500, { + ok: false, + error: error instanceof Error ? error.message : "internal server error", + }); + } + }); + + return new Promise((resolve, reject) => { + server.listen(port, "127.0.0.1", () => { + resolve({ + baseUrl: `http://127.0.0.1:${port}`, + stop: async () => { + await new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) { + rejectClose(error); + return; + } + resolveClose(); + }); + }); + }, + }); + }); + server.on("error", reject); + }); +} + +async function getStackflowRuntimeInfo(stackflowBaseUrl) { + const response = await fetch(`${stackflowBaseUrl}/health`); + if (response.status !== 200) { + throw new Error(`stackflow health failed with status ${response.status}`); + } + const body = await response.json(); + const principal = extractCounterpartyPrincipal(body); + if (!principal) { + throw new Error("stackflow did not report counterpartyPrincipal"); + } + const disputeEnabled = isRecord(body) ? body.disputeEnabled === true : false; + const signerAddress = + isRecord(body) && typeof body.signerAddress === "string" ? body.signerAddress : null; + return { + counterpartyPrincipal: principal, + disputeEnabled, + signerAddress, + }; +} + +function healthHostForBindHost(host) { + if (host === "0.0.0.0") { + return "127.0.0.1"; + } + return host; +} + +async function main() { + const demoConfig = loadDemoConfig(); + const streamChildLogs = parseBoolean(process.env.DEMO_X402_SHOW_CHILD_LOGS, true); + + console.log("[browser-demo] config loaded"); + console.log(`[browser-demo] config file: ${demoConfig.configPath}`); + console.log(`[browser-demo] network=${demoConfig.stacksNetwork} contract=${demoConfig.contractId}`); + console.log( + `[browser-demo] stacks-node observer target: ${demoConfig.stacksNodeEventsObserver}`, + ); + console.log( + `[browser-demo] stacks-node config: stacks_node_events_observers = [\"${demoConfig.stacksNodeEventsObserver}\"]`, + ); + + console.log("[browser-demo] building stackflow-node artifacts..."); + execFileSync("npm", ["run", "-s", "build:stackflow-node"], { + cwd: ROOT, + stdio: "inherit", + }); + + const stackflowHost = demoConfig.stackflowNodeHost; + const stackflowPort = demoConfig.stackflowNodePort; + const upstreamPort = await getFreePort(); + const gatewayPort = await getFreePort(); + const counterpartySignerKey = + process.env.DEMO_X402_COUNTERPARTY_KEY?.trim() || buildRandomCounterpartyKey(); + const disputeSigner = selectDisputeSignerKey({ + network: demoConfig.stacksNetwork, + counterpartySignerKey, + }); + const disputeSignerKey = disputeSigner.disputeSignerKey; + const dbFile = path.join( + os.tmpdir(), + `stackflow-x402-browser-demo-${Date.now()}-${Math.random().toString(16).slice(2)}.db`, + ); + + const stackflowBaseUrl = `http://${healthHostForBindHost(stackflowHost)}:${stackflowPort}`; + const stackflowEnv = { + STACKFLOW_NODE_HOST: stackflowHost, + STACKFLOW_NODE_PORT: String(stackflowPort), + STACKFLOW_NODE_DB_FILE: dbFile, + STACKFLOW_CONTRACTS: demoConfig.contractId, + STACKS_NETWORK: demoConfig.stacksNetwork, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: "accept-all", + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: "auto", + STACKFLOW_NODE_DISPUTE_SIGNER_KEY: disputeSignerKey, + STACKFLOW_NODE_COUNTERPARTY_KEY: counterpartySignerKey, + STACKFLOW_NODE_FORWARDING_ENABLED: "false", + STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY: demoConfig.observerLocalhostOnly + ? "true" + : "false", + }; + if (demoConfig.observerAllowedIps.length > 0) { + stackflowEnv.STACKFLOW_NODE_OBSERVER_ALLOWED_IPS = demoConfig.observerAllowedIps.join(","); + } + if (demoConfig.stacksApiUrl) { + stackflowEnv.STACKS_API_URL = demoConfig.stacksApiUrl; + } + + const stackflow = await startChildProcess({ + label: "stackflow-node", + entry: STACKFLOW_ENTRY, + baseUrl: stackflowBaseUrl, + env: stackflowEnv, + streamLogs: streamChildLogs, + }); + + let site = null; + let gateway = null; + let shuttingDown = false; + + const shutdown = async () => { + if (shuttingDown) { + return; + } + shuttingDown = true; + console.log("\n[browser-demo] shutting down..."); + if (gateway) { + await gateway.stop().catch(() => {}); + } + if (site) { + await site.stop().catch(() => {}); + } + await stackflow.stop().catch(() => {}); + cleanupDbFiles(dbFile); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + try { + const runtimeInfo = await getStackflowRuntimeInfo(stackflowBaseUrl); + const counterpartyPrincipal = runtimeInfo.counterpartyPrincipal; + console.log(`[browser-demo] counterparty principal: ${counterpartyPrincipal}`); + console.log( + `[browser-demo] disputes enabled: ${runtimeInfo.disputeEnabled} signer=${runtimeInfo.signerAddress || "-"}`, + ); + console.log(`[browser-demo] dispute signer source: ${disputeSigner.source}`); + console.log( + "[browser-demo] note: pipe state is read only from observer-fed stackflow-node /pipes", + ); + + site = await createDemoSiteServer({ + port: upstreamPort, + stackflowBaseUrl, + counterpartyPrincipal, + demoConfig, + }); + + const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`; + gateway = await startChildProcess({ + label: "x402-gateway", + entry: GATEWAY_ENTRY, + baseUrl: gatewayBaseUrl, + streamLogs: streamChildLogs, + env: { + STACKFLOW_X402_GATEWAY_HOST: "127.0.0.1", + STACKFLOW_X402_GATEWAY_PORT: String(gatewayPort), + STACKFLOW_X402_UPSTREAM_BASE_URL: site.baseUrl, + STACKFLOW_X402_STACKFLOW_NODE_BASE_URL: stackflowBaseUrl, + STACKFLOW_X402_PROTECTED_PATH: "/paywalled-story", + STACKFLOW_X402_PRICE_AMOUNT: demoConfig.priceAmount, + STACKFLOW_X402_PRICE_ASSET: demoConfig.priceAsset, + }, + }); + + console.log("[browser-demo] ready"); + console.log(`[browser-demo] open in browser: ${gatewayBaseUrl}/`); + console.log("[browser-demo] click \"Read premium story\" to trigger the 402 flow"); + console.log("[browser-demo] press Ctrl+C to stop"); + } catch (error) { + console.error( + `[browser-demo] failed: ${error instanceof Error ? error.message : String(error)}`, + ); + await shutdown(); + } +} + +main().catch((error) => { + console.error( + `[browser-demo] fatal: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); +}); diff --git a/scripts/demo-x402-e2e.js b/scripts/demo-x402-e2e.js new file mode 100644 index 0000000..c5c0603 --- /dev/null +++ b/scripts/demo-x402-e2e.js @@ -0,0 +1,514 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { once } from 'node:events'; +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = process.cwd(); +const STACKFLOW_ENTRY = path.join(ROOT, 'server', 'dist', 'index.js'); +const GATEWAY_ENTRY = path.join(ROOT, 'server', 'dist', 'x402-gateway.js'); +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const COUNTERPARTY_SIGNER_KEY = + '7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801'; +const SIG_B = `0x${'22'.repeat(65)}`; + +function toBase64UrlJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function cleanupDbFiles(dbFile) { + for (const suffix of ['', '-wal', '-shm']) { + const file = `${dbFile}${suffix}`; + if (fs.existsSync(file)) { + fs.unlinkSync(file); + } + } +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('failed to allocate free port')); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + server.on('error', reject); + }); +} + +async function waitForHealth(baseUrl, label, child, logsRef) { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`${label} exited before health check.\n${logsRef.join('')}`); + } + try { + const response = await fetch(`${baseUrl}/health`); + if (response.status === 200) { + return; + } + } catch { + // retry + } + await sleep(100); + } + throw new Error(`${label} health timeout.\n${logsRef.join('')}`); +} + +function assertStatus(response, expected, label) { + if (response.status !== expected) { + throw new Error(`${label}: expected status ${expected}, got ${response.status}`); + } +} + +async function fetchJson(url, init) { + const response = await fetch(url, init); + const text = await response.text(); + let body = null; + if (text.trim()) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + return { response, body }; +} + +function peerHeaders(seed) { + return { + 'content-type': 'application/json', + 'x-stackflow-protocol-version': '1', + 'x-stackflow-request-id': `demo-req-${seed}`, + 'idempotency-key': `demo-idem-${seed}`, + }; +} + +function hashSecret(secretHex) { + const normalized = secretHex.startsWith('0x') ? secretHex.slice(2) : secretHex; + return `0x${createHash('sha256').update(Buffer.from(normalized, 'hex')).digest('hex')}`; +} + +function transferPayload({ + forPrincipal, + withPrincipal, + myBalance, + theirBalance, + nonce, + hashedSecret, +}) { + return { + contractId: CONTRACT_ID, + forPrincipal, + withPrincipal, + token: null, + amount: '0', + myBalance, + theirBalance, + theirSignature: SIG_B, + nonce, + action: '1', + actor: withPrincipal, + ...(hashedSecret + ? { + hashedSecret, + secret: hashedSecret, + } + : { secret: null }), + validAfter: null, + beneficialOnly: false, + }; +} + +function forwardingPayload({ + paymentId, + incomingAmount, + outgoingAmount, + hashedSecret, + incoming, + outgoingBaseUrl, + outgoingPayload, +}) { + return { + paymentId, + incomingAmount, + outgoingAmount, + hashedSecret, + incoming, + outgoing: { + baseUrl: outgoingBaseUrl, + endpoint: '/counterparty/transfer', + payload: outgoingPayload, + }, + }; +} + +async function startUpstreamServer(port) { + const server = http.createServer((request, response) => { + const pathname = new URL(request.url || '/', 'http://localhost').pathname; + if (request.method === 'GET' && pathname === '/health') { + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ ok: true, service: 'demo-upstream' })); + return; + } + if (request.method === 'GET' && pathname === '/paid-content') { + const verified = request.headers['x-stackflow-x402-verified'] === 'true'; + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); + response.end( + JSON.stringify({ + ok: true, + source: 'upstream', + content: 'premium payload', + x402Verified: verified, + proofHash: + typeof request.headers['x-stackflow-x402-proof-hash'] === 'string' + ? request.headers['x-stackflow-x402-proof-hash'] + : null, + }), + ); + return; + } + + response.writeHead(404, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + + await new Promise((resolve, reject) => { + server.listen(port, '127.0.0.1', () => resolve()); + server.on('error', reject); + }); + return { + baseUrl: `http://127.0.0.1:${port}`, + stop: async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }, + }; +} + +async function startMockNextHopServer(port) { + const requests = []; + const server = http.createServer((request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + let body = {}; + if (chunks.length > 0) { + try { + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + body = {}; + } + } + + const pathname = new URL(request.url || '/', 'http://localhost').pathname; + if (request.method === 'GET' && pathname === '/health') { + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ ok: true, service: 'demo-next-hop' })); + return; + } + if (request.method === 'POST' && pathname === '/counterparty/transfer') { + requests.push({ headers: request.headers, body }); + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); + response.end( + JSON.stringify({ + ok: true, + mySignature: `0x${'11'.repeat(65)}`, + theirSignature: SIG_B, + }), + ); + return; + } + + response.writeHead(404, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ ok: false, error: 'not found' })); + }); + }); + + await new Promise((resolve, reject) => { + server.listen(port, '127.0.0.1', () => resolve()); + server.on('error', reject); + }); + + return { + baseUrl: `http://127.0.0.1:${port}`, + requests, + stop: async () => { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + }, + }; +} + +async function startChildProcess({ label, entry, env, healthBaseUrl }) { + const logsRef = []; + const child = spawn('node', [entry], { + cwd: ROOT, + env: { + ...process.env, + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout.on('data', (chunk) => logsRef.push(chunk.toString('utf8'))); + child.stderr.on('data', (chunk) => logsRef.push(chunk.toString('utf8'))); + + await waitForHealth(healthBaseUrl, label, child, logsRef); + + return { + logs: logsRef, + stop: async () => { + if (child.exitCode !== null) { + return; + } + child.kill('SIGTERM'); + await once(child, 'exit'); + }, + }; +} + +async function runDemo() { + console.log('[demo] building stackflow server artifacts...'); + execFileSync('npm', ['run', '-s', 'build:stackflow-node'], { + cwd: ROOT, + stdio: 'inherit', + }); + + const stackflowPort = await getFreePort(); + const gatewayPort = await getFreePort(); + const upstreamPort = await getFreePort(); + const nextHopPort = await getFreePort(); + const dbFile = path.join( + os.tmpdir(), + `stackflow-x402-demo-${Date.now()}-${Math.random().toString(16).slice(2)}.db`, + ); + + const stackflowBaseUrl = `http://127.0.0.1:${stackflowPort}`; + const gatewayBaseUrl = `http://127.0.0.1:${gatewayPort}`; + + const upstream = await startUpstreamServer(upstreamPort); + const nextHop = await startMockNextHopServer(nextHopPort); + const stackflow = await startChildProcess({ + label: 'stackflow-node', + entry: STACKFLOW_ENTRY, + healthBaseUrl: stackflowBaseUrl, + env: { + STACKFLOW_NODE_HOST: '127.0.0.1', + STACKFLOW_NODE_PORT: String(stackflowPort), + STACKFLOW_NODE_DB_FILE: dbFile, + STACKFLOW_CONTRACTS: CONTRACT_ID, + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'accept-all', + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'noop', + STACKFLOW_NODE_COUNTERPARTY_KEY: COUNTERPARTY_SIGNER_KEY, + STACKFLOW_NODE_FORWARDING_ENABLED: 'true', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '1', + STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS: 'true', + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: nextHop.baseUrl, + }, + }); + + const gateway = await startChildProcess({ + label: 'x402-gateway', + entry: GATEWAY_ENTRY, + healthBaseUrl: gatewayBaseUrl, + env: { + STACKFLOW_X402_GATEWAY_HOST: '127.0.0.1', + STACKFLOW_X402_GATEWAY_PORT: String(gatewayPort), + STACKFLOW_X402_UPSTREAM_BASE_URL: upstream.baseUrl, + STACKFLOW_X402_STACKFLOW_NODE_BASE_URL: stackflowBaseUrl, + STACKFLOW_X402_PROTECTED_PATH: '/paid-content', + STACKFLOW_X402_PRICE_AMOUNT: '10', + STACKFLOW_X402_PRICE_ASSET: 'STX', + STACKFLOW_X402_INDIRECT_WAIT_TIMEOUT_MS: '15000', + STACKFLOW_X402_INDIRECT_POLL_INTERVAL_MS: '250', + }, + }); + + try { + console.log('[demo] services ready'); + + const health = await fetchJson(`${stackflowBaseUrl}/health`); + assertStatus(health.response, 200, 'stackflow health'); + if (!health.body || typeof health.body !== 'object') { + throw new Error('invalid stackflow health payload'); + } + const counterpartyPrincipal = health.body.counterpartyPrincipal; + if (typeof counterpartyPrincipal !== 'string') { + throw new Error('counterparty principal missing in stackflow health response'); + } + const requestorPrincipal = counterpartyPrincipal === P1 ? P2 : P1; + + const baseline = { + contractId: CONTRACT_ID, + forPrincipal: counterpartyPrincipal, + withPrincipal: requestorPrincipal, + token: null, + myBalance: '900', + theirBalance: '100', + mySignature: `0x${'11'.repeat(65)}`, + theirSignature: SIG_B, + nonce: '5', + action: '1', + actor: requestorPrincipal, + secret: null, + validAfter: null, + beneficialOnly: false, + }; + const seedBaseline = await fetchJson(`${stackflowBaseUrl}/signature-states`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(baseline), + }); + assertStatus(seedBaseline.response, 200, 'seed baseline'); + console.log('[demo] baseline state seeded'); + + const unpaid = await fetchJson(`${gatewayBaseUrl}/paid-content`); + assertStatus(unpaid.response, 402, 'unpaid request'); + console.log('[demo] unpaid -> 402 confirmed'); + + const directProof = { + mode: 'direct', + proof: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal: requestorPrincipal, + myBalance: '910', + theirBalance: '90', + nonce: '6', + }), + }; + directProof.proof.amount = '10'; + + const direct = await fetchJson(`${gatewayBaseUrl}/paid-content`, { + method: 'GET', + headers: { + 'x-x402-payment': toBase64UrlJson(directProof), + }, + }); + assertStatus(direct.response, 200, 'direct paid request'); + if (!direct.body || typeof direct.body !== 'object' || direct.body.x402Verified !== true) { + throw new Error('direct paid request did not reach upstream as verified'); + } + console.log('[demo] direct payment -> payload delivered'); + + const indirectSecret = + '0x8484848484848484848484848484848484848484848484848484848484848484'; + const hashedSecret = hashSecret(indirectSecret); + const indirectPaymentId = `pay-indirect-${Date.now()}`; + + const indirectProof = { + mode: 'indirect', + paymentId: indirectPaymentId, + secret: indirectSecret, + expectedFromPrincipal: requestorPrincipal, + }; + + const indirectStartedAt = Date.now(); + const indirectRequestPromise = fetchJson(`${gatewayBaseUrl}/paid-content`, { + method: 'GET', + headers: { + 'x-x402-payment': toBase64UrlJson(indirectProof), + }, + }); + + await sleep(1200); + const forward = await fetchJson(`${stackflowBaseUrl}/forwarding/transfer`, { + method: 'POST', + headers: peerHeaders(`indirect-${Date.now()}`), + body: JSON.stringify( + forwardingPayload({ + paymentId: indirectPaymentId, + incomingAmount: '100', + outgoingAmount: '90', + hashedSecret, + incoming: transferPayload({ + forPrincipal: counterpartyPrincipal, + withPrincipal: requestorPrincipal, + myBalance: '920', + theirBalance: '80', + nonce: '7', + hashedSecret, + }), + outgoingBaseUrl: nextHop.baseUrl, + outgoingPayload: { + contractId: CONTRACT_ID, + forPrincipal: P2, + withPrincipal: counterpartyPrincipal, + token: null, + amount: '0', + myBalance: '500', + theirBalance: '500', + theirSignature: SIG_B, + nonce: '11', + action: '1', + actor: counterpartyPrincipal, + hashedSecret, + secret: null, + validAfter: null, + beneficialOnly: false, + }, + }), + ), + }); + assertStatus(forward.response, 200, 'create forwarding payment for indirect mode'); + + const indirect = await indirectRequestPromise; + const indirectDurationMs = Date.now() - indirectStartedAt; + assertStatus(indirect.response, 200, 'indirect paid request'); + if (!indirect.body || typeof indirect.body !== 'object' || indirect.body.x402Verified !== true) { + throw new Error('indirect paid request did not reach upstream as verified'); + } + console.log( + `[demo] indirect payment (wait + reveal) -> payload delivered (waited ${indirectDurationMs}ms)`, + ); + + const paymentCheck = await fetchJson( + `${stackflowBaseUrl}/forwarding/payments?paymentId=${encodeURIComponent(indirectPaymentId)}`, + ); + assertStatus(paymentCheck.response, 200, 'forwarding payment check'); + if ( + !paymentCheck.body || + typeof paymentCheck.body !== 'object' || + !paymentCheck.body.payment || + typeof paymentCheck.body.payment !== 'object' || + !paymentCheck.body.payment.revealedAt + ) { + throw new Error('forwarding payment does not show revealedAt after indirect flow'); + } + console.log('[demo] forwarding payment reveal confirmed'); + + console.log('\n[demo] success: unpaid, direct, and indirect x402 flows all completed'); + console.log(`[demo] stackflow-node: ${stackflowBaseUrl}`); + console.log(`[demo] x402-gateway: ${gatewayBaseUrl}`); + console.log(`[demo] upstream app: ${upstream.baseUrl}`); + } finally { + await gateway.stop().catch(() => {}); + await stackflow.stop().catch(() => {}); + await nextHop.stop().catch(() => {}); + await upstream.stop().catch(() => {}); + cleanupDbFiles(dbFile); + } +} + +runDemo().catch((error) => { + const message = error instanceof Error ? error.stack || error.message : String(error); + console.error(`[demo] failed: ${message}`); + process.exit(1); +}); diff --git a/server/X402_GATEWAY_DESIGN.md b/server/X402_GATEWAY_DESIGN.md new file mode 100644 index 0000000..3c0f437 --- /dev/null +++ b/server/X402_GATEWAY_DESIGN.md @@ -0,0 +1,237 @@ +# Stackflow x402 Gateway Design + +## Purpose + +The x402 gateway is an HTTP layer in front of an application server that: + +1. challenges unpaid requests with HTTP `402 Payment Required` +2. verifies payment using Stackflow APIs +3. forwards the request upstream only after payment verification succeeds + +The gateway currently supports two payment modes: + +1. `direct`: immediate verification from the requestor proof +2. `indirect`: wait for a forwarded payment record and validate a reveal secret + +## Scope and Status + +Implementation entrypoint: `server/src/x402-gateway.ts` + +Current scaffold scope: + +1. one protected path (`STACKFLOW_X402_PROTECTED_PATH`) +2. one in-memory replay set keyed by `(method, path+query, proof payload hash)` +3. direct verification using `POST /counterparty/transfer` +4. indirect verification using: + - `GET /forwarding/payments?paymentId=` + - `POST /forwarding/reveal` + +## Architecture + +Runtime components: + +1. client (payer) +2. x402 gateway (public ingress) +3. stackflow-node (private/internal service) +4. upstream application server (private/internal service) +5. optional next-hop stackflow-node(s) for routed payments + +Data/control flow: + +1. client requests protected resource +2. gateway checks `x-x402-payment` +3. gateway verifies payment with stackflow-node +4. gateway marks proof as consumed (TTL window) +5. gateway proxies request to upstream app with verification headers + +## Request Protocol + +Protected route: request path must equal `STACKFLOW_X402_PROTECTED_PATH`. + +Payment proof transport: + +1. header: `x-x402-payment` +2. format: JSON string or base64url-encoded JSON + +On missing/invalid proof, gateway returns: + +1. status `402` +2. `WWW-Authenticate: X402 ...` +3. machine-readable JSON payload describing accepted modes and fields + +## Verification Modes + +### Direct Mode + +Accepted proof shape: + +1. `mode: "direct"` (optional; if omitted, payload is treated as direct) +2. direct transfer proof fields (`action = 1`) compatible with + `POST /counterparty/transfer` + +Verification steps: + +1. parse and validate fields (`amount`, balances, nonce, signatures, etc.) +2. enforce `amount >= STACKFLOW_X402_PRICE_AMOUNT` +3. call `POST /counterparty/transfer` on stackflow-node with peer headers +4. require stackflow response `2xx` and `ok: true` +5. proxy upstream if accepted + +### Indirect Mode + +Accepted proof shape: + +1. `mode: "indirect"` +2. `paymentId` +3. `secret` (32-byte hex preimage) +4. `expectedFromPrincipal` + +Verification steps: + +1. poll `GET /forwarding/payments?paymentId=...` until timeout +2. require payment exists and `status = completed` +3. require forwarding metadata indicates payer principal matches `expectedFromPrincipal` +4. require payment includes `hashedSecret` +5. call `POST /forwarding/reveal` with `{ paymentId, secret }` +6. require reveal response `2xx` and `ok: true` +7. proxy upstream if accepted + +## Upstream Proxy Behavior + +For verified requests, gateway forwards all non-hop-by-hop headers and adds: + +1. `x-stackflow-x402-verified: true` +2. `x-stackflow-x402-proof-hash: ` + +For unprotected routes, gateway proxies without requiring payment. + +## Replay Handling + +Replay defense is currently in-memory and process-local: + +1. replay key = hash of method + path/query + normalized proof payload +2. consumed key retained for `STACKFLOW_X402_PROOF_REPLAY_TTL_MS` +3. a replayed key returns `402` with `payment-proof-already-used` + +Implications: + +1. restart clears consumed proof memory +2. multi-instance deployments do not share replay state by default + +## Configuration + +Core: + +1. `STACKFLOW_X402_GATEWAY_HOST` (default `127.0.0.1`) +2. `STACKFLOW_X402_GATEWAY_PORT` (default `8790`) +3. `STACKFLOW_X402_UPSTREAM_BASE_URL` (default `http://127.0.0.1:3000`) +4. `STACKFLOW_X402_STACKFLOW_NODE_BASE_URL` (default `http://127.0.0.1:8787`) +5. `STACKFLOW_X402_PROTECTED_PATH` (default `/paid-content`) +6. `STACKFLOW_X402_PRICE_AMOUNT` (default `1000`) +7. `STACKFLOW_X402_PRICE_ASSET` (default `STX`) + +Timeouts and polling: + +1. `STACKFLOW_X402_STACKFLOW_TIMEOUT_MS` (default `10000`) +2. `STACKFLOW_X402_UPSTREAM_TIMEOUT_MS` (default `10000`) +3. `STACKFLOW_X402_PROOF_REPLAY_TTL_MS` (default `86400000`) +4. `STACKFLOW_X402_INDIRECT_WAIT_TIMEOUT_MS` (default `30000`) +5. `STACKFLOW_X402_INDIRECT_POLL_INTERVAL_MS` (default `1000`) + +Indirect read auth: + +1. `STACKFLOW_X402_STACKFLOW_ADMIN_READ_TOKEN` (optional) +2. fallback: `STACKFLOW_NODE_ADMIN_READ_TOKEN` + +## Production Deployment Guidance + +### Network Topology + +Recommended: + +1. expose only the gateway publicly +2. keep stackflow-node on private network or localhost-only bind +3. keep upstream app private behind gateway +4. keep observer endpoints restricted to trusted sources/localhost + +### TLS and Ingress + +1. terminate TLS at ingress or run end-to-end TLS/mTLS +2. apply standard edge protections: WAF, rate limits, request size limits +3. if behind trusted proxy chain, configure stackflow-node proxy trust carefully + +### Auth and Access Separation + +1. payment callers interact with gateway only +2. do not expose stackflow-node admin/sensitive endpoints directly +3. when indirect mode is used and admin-read token is set, pass token only from + gateway to stackflow-node over trusted internal network + +### Single-Instance vs Multi-Instance + +Single instance is simplest and currently recommended. + +For multi-instance gateway: + +1. move replay state from memory to shared durable store (Redis/DB) +2. use deterministic idempotency keys across replicas +3. ensure consistent route pricing policy across replicas + +### Failure Handling Policy + +Define explicit external behavior for: + +1. stackflow-node timeout/unavailable -> return `402` with reason +2. indirect payment wait timeout -> return `402` with timeout reason +3. upstream timeout/unavailable after payment accept -> return retriable `5xx` + +## Security Considerations + +1. Treat all payment proof inputs as untrusted. +2. Validate mode-specific schema before any downstream call. +3. Keep stackflow-node forwarding restrictions enabled (allowed base URLs, private-destination policy). +4. Do not log raw secrets or signatures in plaintext in production logs. +5. Bound request body size and header size at ingress. +6. Run gateway and stackflow-node with least-privilege OS/container permissions. + +## Observability and Operations + +Recommended telemetry: + +1. counters: + - `x402_challenge_total` + - `x402_direct_accept_total` + - `x402_indirect_accept_total` + - `x402_reject_total{reason=...}` +2. latency histograms: + - direct verification latency + - indirect wait duration + - upstream proxy latency +3. gauges: + - in-memory replay set size + +Recommended structured log fields: + +1. `request_id` +2. `mode` (`direct|indirect`) +3. `proof_hash` +4. `payment_id` (indirect) +5. `decision` (`challenged|accepted|rejected`) +6. `reason` + +## Known Limitations and Next Steps + +Current limitations: + +1. one protected path instead of route policy table +2. no persistent/shared replay store +3. no dynamic pricing policy per route/method/tenant +4. no settlement finality policy layer beyond stackflow-node acceptance + +Planned improvements: + +1. route policy config map (`method+path -> price/asset/mode policy`) +2. shared replay/idempotency backend for HA +3. richer indirect payer attestation model beyond principal equality +4. metrics and structured logging integration +5. integration tests for gateway-specific negative cases and chaos scenarios diff --git a/server/src/x402-gateway.ts b/server/src/x402-gateway.ts new file mode 100644 index 0000000..678944c --- /dev/null +++ b/server/src/x402-gateway.ts @@ -0,0 +1,1024 @@ +import 'dotenv/config'; + +import { createHash, randomUUID } from 'node:crypto'; +import http from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import process from 'node:process'; + +const DEFAULT_GATEWAY_HOST = '127.0.0.1'; +const DEFAULT_GATEWAY_PORT = 8790; +const DEFAULT_UPSTREAM_BASE_URL = 'http://127.0.0.1:3000'; +const DEFAULT_STACKFLOW_NODE_BASE_URL = 'http://127.0.0.1:8787'; +const DEFAULT_PROTECTED_PATH = '/paid-content'; +const DEFAULT_PRICE_AMOUNT = '1000'; +const DEFAULT_PRICE_ASSET = 'STX'; +const DEFAULT_STACKFLOW_TIMEOUT_MS = 10_000; +const DEFAULT_UPSTREAM_TIMEOUT_MS = 10_000; +const DEFAULT_PROOF_REPLAY_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_INDIRECT_WAIT_TIMEOUT_MS = 30_000; +const DEFAULT_INDIRECT_POLL_INTERVAL_MS = 1_000; +const PEER_PROTOCOL_VERSION = '1'; +const HEADER_X402_PAYMENT = 'x-x402-payment'; +const HEADER_PEER_PROTOCOL_VERSION = 'x-stackflow-protocol-version'; +const HEADER_PEER_REQUEST_ID = 'x-stackflow-request-id'; +const HEADER_IDEMPOTENCY_KEY = 'idempotency-key'; +const MAX_PROTOCOL_ID_LENGTH = 128; +const PROTOCOL_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/; +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +interface GatewayConfig { + host: string; + port: number; + upstreamBaseUrl: string; + stackflowNodeBaseUrl: string; + protectedPath: string; + priceAmount: string; + priceAsset: string; + stackflowTimeoutMs: number; + upstreamTimeoutMs: number; + proofReplayTtlMs: number; + indirectWaitTimeoutMs: number; + indirectPollIntervalMs: number; + stackflowAdminReadToken: string | null; +} + +interface PeerRequestMetadata { + requestId: string; + idempotencyKey: string; +} + +interface CounterpartyTransferProof { + contractId: string; + forPrincipal: string; + withPrincipal: string; + token: string | null; + amount: string; + myBalance: string; + theirBalance: string; + theirSignature: string; + nonce: string; + action: string; + actor: string; + hashedSecret: string | null; + validAfter: string | null; + beneficialOnly: boolean; +} + +interface DirectGatewayPaymentProof { + mode: 'direct'; + proof: CounterpartyTransferProof; +} + +interface IndirectGatewayPaymentProof { + mode: 'indirect'; + paymentId: string; + secret: string; + expectedFromPrincipal: string; +} + +type GatewayPaymentProof = DirectGatewayPaymentProof | IndirectGatewayPaymentProof; + +interface StackflowResponse { + statusCode: number; + body: Record; +} + +interface ForwardingPaymentLookup { + paymentId: string; + status: 'completed' | 'failed'; + hashedSecret: string | null; + revealedSecret: string | null; + upstreamWithPrincipal: string | null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function parseInteger(value: unknown, fallback: number): number { + if (value === undefined || value === null || value === '') { + return fallback; + } + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseUintString(value: unknown, fieldName: string): string { + const text = String(value ?? '').trim(); + if (!/^[0-9]+$/.test(text)) { + throw new Error(`${fieldName} must be a uint string`); + } + return text; +} + +function parsePaymentId(value: unknown, fieldName: string): string { + const text = String(value ?? '').trim(); + if ( + text.length < 8 || + text.length > MAX_PROTOCOL_ID_LENGTH || + !PROTOCOL_ID_PATTERN.test(text) + ) { + throw new Error(`${fieldName} must be 8-128 chars [a-zA-Z0-9._:-]`); + } + return text; +} + +function parseHex32(value: unknown, fieldName: string): string { + const text = String(value ?? '').trim().toLowerCase(); + const normalized = text.startsWith('0x') ? text : `0x${text}`; + if (!/^0x[0-9a-f]{64}$/.test(normalized)) { + throw new Error(`${fieldName} must be 32-byte hex`); + } + return normalized; +} + +function normalizeBaseUrl(input: string, fieldName: string): string { + let parsed: URL; + try { + parsed = new URL(input.trim()); + } catch { + throw new Error(`${fieldName} must be a valid URL`); + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`${fieldName} must use http/https`); + } + + parsed.pathname = ''; + parsed.search = ''; + parsed.hash = ''; + const normalized = parsed.toString(); + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; +} + +function normalizePath(input: string, fieldName: string): string { + const text = input.trim(); + if (!text.startsWith('/')) { + throw new Error(`${fieldName} must start with /`); + } + return text; +} + +function normalizeProtocolId(value: string, fallbackPrefix: string): string { + const candidate = value.trim(); + if ( + candidate.length >= 8 && + candidate.length <= MAX_PROTOCOL_ID_LENGTH && + PROTOCOL_ID_PATTERN.test(candidate) + ) { + return candidate; + } + + const generated = `${fallbackPrefix}-${randomUUID().replace(/-/g, '')}`.slice( + 0, + MAX_PROTOCOL_ID_LENGTH, + ); + return generated; +} + +function readHeaderValue(request: IncomingMessage, headerName: string): string | null { + const value = request.headers[headerName]; + if (Array.isArray(value)) { + return value.find((item) => item.trim().length > 0)?.trim() || null; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + return null; +} + +function decodeBase64Url(input: string): string { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ); + return Buffer.from(padded, 'base64').toString('utf8'); +} + +function parsePaymentProofHeader(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${HEADER_X402_PAYMENT} is empty`); + } + + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed); + } + return JSON.parse(decodeBase64Url(trimmed)); +} + +function toOptionalStringOrNull(value: unknown): string | null { + if (value === undefined || value === null || value === '') { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function toBoolean(value: unknown, fallback: boolean): boolean { + if (value === undefined || value === null || value === '') { + return fallback; + } + if (typeof value === 'boolean') { + return value; + } + const text = String(value).trim().toLowerCase(); + if (text === 'true' || text === '1' || text === 'yes' || text === 'on') { + return true; + } + if (text === 'false' || text === '0' || text === 'no' || text === 'off') { + return false; + } + return fallback; +} + +function buildCounterpartyTransferProof( + value: unknown, + config: GatewayConfig, +): CounterpartyTransferProof { + if (!isRecord(value)) { + throw new Error('payment proof must be a JSON object'); + } + + const contractId = String(value.contractId ?? '').trim(); + const forPrincipal = String(value.forPrincipal ?? '').trim(); + const withPrincipal = String(value.withPrincipal ?? '').trim(); + const actor = String(value.actor ?? '').trim(); + const theirSignature = String( + (value.theirSignature ?? value.counterpartySignature) ?? '', + ).trim(); + const amount = parseUintString(value.amount, 'amount'); + + if (!contractId) { + throw new Error('contractId is required'); + } + if (!forPrincipal) { + throw new Error('forPrincipal is required'); + } + if (!withPrincipal) { + throw new Error('withPrincipal is required'); + } + if (!actor) { + throw new Error('actor is required'); + } + if (!theirSignature) { + throw new Error('theirSignature is required'); + } + + if (BigInt(amount) < BigInt(config.priceAmount)) { + throw new Error( + `amount must be >= configured price (${config.priceAmount} ${config.priceAsset})`, + ); + } + + const action = + value.action === undefined || value.action === null || value.action === '' + ? '1' + : parseUintString(value.action, 'action'); + if (action !== '1') { + throw new Error('direct x402 payment proof must use action=1'); + } + + const token = toOptionalStringOrNull(value.token); + const hashedSecret = toOptionalStringOrNull(value.hashedSecret); + const validAfter = toOptionalStringOrNull(value.validAfter); + + return { + contractId, + forPrincipal, + withPrincipal, + token, + amount, + myBalance: parseUintString(value.myBalance, 'myBalance'), + theirBalance: parseUintString(value.theirBalance, 'theirBalance'), + theirSignature, + nonce: parseUintString(value.nonce, 'nonce'), + action, + actor, + hashedSecret, + validAfter, + beneficialOnly: toBoolean(value.beneficialOnly, false), + }; +} + +function parseExpectedFromPrincipal(value: unknown): string { + const text = String(value ?? '').trim(); + if (!text) { + throw new Error('expectedFromPrincipal is required'); + } + return text; +} + +function buildGatewayPaymentProof( + value: unknown, + config: GatewayConfig, +): GatewayPaymentProof { + if (!isRecord(value)) { + throw new Error('payment proof must be a JSON object'); + } + + const mode = String(value.mode ?? '').trim().toLowerCase(); + if (mode === 'indirect') { + return { + mode: 'indirect', + paymentId: parsePaymentId(value.paymentId, 'paymentId'), + secret: parseHex32(value.secret, 'secret'), + expectedFromPrincipal: parseExpectedFromPrincipal( + value.expectedFromPrincipal ?? value.fromPrincipal, + ), + }; + } + + const directSource = + mode === 'direct' && isRecord(value.proof) ? value.proof : value; + return { + mode: 'direct', + proof: buildCounterpartyTransferProof(directSource, config), + }; +} + +function buildStackflowTransferPayload(proof: CounterpartyTransferProof): Record { + return { + contractId: proof.contractId, + forPrincipal: proof.forPrincipal, + withPrincipal: proof.withPrincipal, + token: proof.token, + amount: proof.amount, + myBalance: proof.myBalance, + theirBalance: proof.theirBalance, + theirSignature: proof.theirSignature, + nonce: proof.nonce, + action: proof.action, + actor: proof.actor, + hashedSecret: proof.hashedSecret, + validAfter: proof.validAfter, + beneficialOnly: proof.beneficialOnly, + }; +} + +function buildProofHash( + method: string, + routeBinding: string, + proof: GatewayPaymentProof, +): string { + const proofPayload = + proof.mode === 'direct' + ? { + mode: proof.mode, + proof: buildStackflowTransferPayload(proof.proof), + } + : { + mode: proof.mode, + paymentId: proof.paymentId, + secret: proof.secret, + expectedFromPrincipal: proof.expectedFromPrincipal, + }; + return createHash('sha256') + .update(method.toUpperCase()) + .update('\n') + .update(routeBinding) + .update('\n') + .update(JSON.stringify(proofPayload)) + .digest('hex'); +} + +function buildPeerMetadata( + request: IncomingMessage, + proofHash: string, + suffix: string, +): PeerRequestMetadata { + const requestIdHeader = readHeaderValue(request, HEADER_PEER_REQUEST_ID); + const requestId = normalizeProtocolId( + requestIdHeader || '', + `x402-gw-${suffix}-req-${proofHash.slice(0, 12)}`, + ); + const idempotencyKey = normalizeProtocolId( + `x402-gw-${suffix}-idem-${proofHash.slice(0, 64)}`, + `x402-gw-${suffix}-idem`, + ); + return { requestId, idempotencyKey }; +} + +function filterRequestHeaders( + request: IncomingMessage, + proofHash: string, +): Record { + const headers: Record = {}; + for (const [key, value] of Object.entries(request.headers)) { + const normalizedKey = key.toLowerCase(); + if ( + HOP_BY_HOP_HEADERS.has(normalizedKey) || + normalizedKey === 'host' || + normalizedKey === 'content-length' || + normalizedKey === HEADER_X402_PAYMENT + ) { + continue; + } + + if (Array.isArray(value)) { + if (value.length > 0) { + headers[key] = value.join(', '); + } + continue; + } + if (typeof value === 'string') { + headers[key] = value; + } + } + + headers['x-stackflow-x402-verified'] = 'true'; + headers['x-stackflow-x402-proof-hash'] = proofHash; + return headers; +} + +function filterResponseHeaders(upstreamResponse: Response): Record { + const headers: Record = {}; + upstreamResponse.headers.forEach((value, key) => { + if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + return; + } + headers[key] = value; + }); + + const withSetCookie = upstreamResponse.headers as Headers & { + getSetCookie?: () => string[]; + }; + const setCookie = withSetCookie.getSetCookie?.(); + if (setCookie && setCookie.length > 0) { + headers['set-cookie'] = setCookie; + } + return headers; +} + +function writeJson( + response: ServerResponse, + statusCode: number, + payload: Record, + extraHeaders: Record = {}, +): void { + response.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + ...extraHeaders, + }); + response.end(JSON.stringify(payload)); +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(chunk); + } + } + return Buffer.concat(chunks); +} + +function formatX402AuthenticateHeader(config: GatewayConfig): string { + return `X402 realm="stackflow", amount="${config.priceAmount}", asset="${config.priceAsset}", path="${config.protectedPath}"`; +} + +function writePaymentRequired( + response: ServerResponse, + config: GatewayConfig, + reason: string, + details: string, +): void { + writeJson( + response, + 402, + { + ok: false, + error: 'payment required', + reason, + details, + payment: { + scheme: 'x402-stackflow-v1', + header: HEADER_X402_PAYMENT, + amount: config.priceAmount, + asset: config.priceAsset, + protectedPath: config.protectedPath, + modes: { + direct: { + action: '1', + requiredFields: [ + 'contractId', + 'forPrincipal', + 'withPrincipal', + 'amount', + 'myBalance', + 'theirBalance', + 'theirSignature', + 'nonce', + 'actor', + ], + }, + indirect: { + requiredFields: ['mode', 'paymentId', 'secret', 'expectedFromPrincipal'], + }, + }, + }, + }, + { + 'www-authenticate': formatX402AuthenticateHeader(config), + }, + ); +} + +async function callStackflowCounterpartyTransfer(args: { + config: GatewayConfig; + payload: Record; + peer: PeerRequestMetadata; +}): Promise { + const { config, payload, peer } = args; + const url = `${config.stackflowNodeBaseUrl}/counterparty/transfer`; + const response = await fetch(url, { + method: 'POST', + redirect: 'error', + headers: { + 'content-type': 'application/json', + [HEADER_PEER_PROTOCOL_VERSION]: PEER_PROTOCOL_VERSION, + [HEADER_PEER_REQUEST_ID]: peer.requestId, + [HEADER_IDEMPOTENCY_KEY]: peer.idempotencyKey, + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(config.stackflowTimeoutMs), + }); + + const body = await response + .json() + .catch(() => ({ ok: false, error: 'stackflow node returned non-json response' })); + + return { + statusCode: response.status, + body: isRecord(body) + ? body + : { ok: false, error: 'stackflow node returned invalid JSON body' }, + }; +} + +function buildStackflowAdminHeaders( + stackflowAdminReadToken: string | null, +): Record { + if (!stackflowAdminReadToken) { + return {}; + } + return { + authorization: `Bearer ${stackflowAdminReadToken}`, + 'x-stackflow-admin-token': stackflowAdminReadToken, + }; +} + +function extractForwardingPaymentLookup(value: unknown): ForwardingPaymentLookup | null { + if (!isRecord(value)) { + return null; + } + + const paymentId = typeof value.paymentId === 'string' ? value.paymentId : null; + const status = value.status; + if ( + !paymentId || + (status !== 'completed' && status !== 'failed') + ) { + return null; + } + + const resultJson = isRecord(value.resultJson) ? value.resultJson : null; + const upstream = resultJson && isRecord(resultJson.upstream) ? resultJson.upstream : null; + const upstreamWithPrincipal = + upstream && typeof upstream.withPrincipal === 'string' + ? upstream.withPrincipal + : null; + + return { + paymentId, + status, + hashedSecret: typeof value.hashedSecret === 'string' ? value.hashedSecret : null, + revealedSecret: typeof value.revealedSecret === 'string' ? value.revealedSecret : null, + upstreamWithPrincipal, + }; +} + +async function fetchForwardingPayment(args: { + config: GatewayConfig; + paymentId: string; +}): Promise { + const { config, paymentId } = args; + const query = new URLSearchParams({ paymentId }); + const response = await fetch( + `${config.stackflowNodeBaseUrl}/forwarding/payments?${query.toString()}`, + { + method: 'GET', + redirect: 'error', + headers: { + ...buildStackflowAdminHeaders(config.stackflowAdminReadToken), + }, + signal: AbortSignal.timeout(config.stackflowTimeoutMs), + }, + ); + + const body = await response + .json() + .catch(() => ({ ok: false, error: 'stackflow node returned non-json response' })); + + if (!response.ok) { + const bodyError = + isRecord(body) && typeof body.error === 'string' ? body.error : 'lookup failed'; + throw new Error( + `forwarding payment lookup failed (status=${response.status}, error=${bodyError})`, + ); + } + + if (!isRecord(body)) { + throw new Error('forwarding payment lookup returned invalid JSON body'); + } + + return extractForwardingPaymentLookup(body.payment); +} + +async function callStackflowForwardingReveal(args: { + config: GatewayConfig; + paymentId: string; + secret: string; + peer: PeerRequestMetadata; +}): Promise { + const { config, paymentId, secret, peer } = args; + const url = `${config.stackflowNodeBaseUrl}/forwarding/reveal`; + const response = await fetch(url, { + method: 'POST', + redirect: 'error', + headers: { + 'content-type': 'application/json', + [HEADER_PEER_PROTOCOL_VERSION]: PEER_PROTOCOL_VERSION, + [HEADER_PEER_REQUEST_ID]: peer.requestId, + [HEADER_IDEMPOTENCY_KEY]: peer.idempotencyKey, + }, + body: JSON.stringify({ + paymentId, + secret, + }), + signal: AbortSignal.timeout(config.stackflowTimeoutMs), + }); + + const body = await response + .json() + .catch(() => ({ ok: false, error: 'stackflow node returned non-json response' })); + + return { + statusCode: response.status, + body: isRecord(body) + ? body + : { ok: false, error: 'stackflow node returned invalid JSON body' }, + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function waitForIndirectPayment(args: { + config: GatewayConfig; + proof: IndirectGatewayPaymentProof; +}): Promise { + const { config, proof } = args; + const deadline = Date.now() + config.indirectWaitTimeoutMs; + let lastError: string | null = null; + + while (Date.now() <= deadline) { + let payment: ForwardingPaymentLookup | null = null; + try { + payment = await fetchForwardingPayment({ config, paymentId: proof.paymentId }); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + if (Date.now() <= deadline) { + await sleep(config.indirectPollIntervalMs); + } + continue; + } + + if (!payment) { + await sleep(config.indirectPollIntervalMs); + continue; + } + + if (payment.status === 'failed') { + throw new Error('indirect payment exists but is marked failed'); + } + + if (!payment.upstreamWithPrincipal) { + throw new Error('indirect payment missing upstream payer metadata'); + } + if (payment.upstreamWithPrincipal !== proof.expectedFromPrincipal) { + throw new Error( + `indirect payment came from ${payment.upstreamWithPrincipal}, expected ${proof.expectedFromPrincipal}`, + ); + } + if (!payment.hashedSecret) { + throw new Error('indirect payment does not include a hashed secret'); + } + + return payment; + } + + throw new Error( + `timed out waiting for indirect payment ${proof.paymentId}${ + lastError ? ` (last-error=${lastError})` : '' + }`, + ); +} + +function loadConfig(env: NodeJS.ProcessEnv = process.env): GatewayConfig { + const host = (env.STACKFLOW_X402_GATEWAY_HOST || DEFAULT_GATEWAY_HOST).trim(); + const port = Math.max( + 1, + parseInteger(env.STACKFLOW_X402_GATEWAY_PORT, DEFAULT_GATEWAY_PORT), + ); + const upstreamBaseUrl = normalizeBaseUrl( + env.STACKFLOW_X402_UPSTREAM_BASE_URL || DEFAULT_UPSTREAM_BASE_URL, + 'STACKFLOW_X402_UPSTREAM_BASE_URL', + ); + const stackflowNodeBaseUrl = normalizeBaseUrl( + env.STACKFLOW_X402_STACKFLOW_NODE_BASE_URL || DEFAULT_STACKFLOW_NODE_BASE_URL, + 'STACKFLOW_X402_STACKFLOW_NODE_BASE_URL', + ); + const protectedPath = normalizePath( + env.STACKFLOW_X402_PROTECTED_PATH || DEFAULT_PROTECTED_PATH, + 'STACKFLOW_X402_PROTECTED_PATH', + ); + const priceAmount = parseUintString( + env.STACKFLOW_X402_PRICE_AMOUNT || DEFAULT_PRICE_AMOUNT, + 'STACKFLOW_X402_PRICE_AMOUNT', + ); + const priceAsset = String(env.STACKFLOW_X402_PRICE_ASSET || DEFAULT_PRICE_ASSET).trim(); + if (priceAsset.length === 0) { + throw new Error('STACKFLOW_X402_PRICE_ASSET must not be empty'); + } + const stackflowAdminReadToken = + env.STACKFLOW_X402_STACKFLOW_ADMIN_READ_TOKEN?.trim() || + env.STACKFLOW_NODE_ADMIN_READ_TOKEN?.trim() || + null; + + return { + host, + port, + upstreamBaseUrl, + stackflowNodeBaseUrl, + protectedPath, + priceAmount, + priceAsset, + stackflowTimeoutMs: Math.max( + 1_000, + parseInteger(env.STACKFLOW_X402_STACKFLOW_TIMEOUT_MS, DEFAULT_STACKFLOW_TIMEOUT_MS), + ), + upstreamTimeoutMs: Math.max( + 1_000, + parseInteger(env.STACKFLOW_X402_UPSTREAM_TIMEOUT_MS, DEFAULT_UPSTREAM_TIMEOUT_MS), + ), + proofReplayTtlMs: Math.max( + 1_000, + parseInteger(env.STACKFLOW_X402_PROOF_REPLAY_TTL_MS, DEFAULT_PROOF_REPLAY_TTL_MS), + ), + indirectWaitTimeoutMs: Math.max( + 1_000, + parseInteger( + env.STACKFLOW_X402_INDIRECT_WAIT_TIMEOUT_MS, + DEFAULT_INDIRECT_WAIT_TIMEOUT_MS, + ), + ), + indirectPollIntervalMs: Math.max( + 200, + parseInteger( + env.STACKFLOW_X402_INDIRECT_POLL_INTERVAL_MS, + DEFAULT_INDIRECT_POLL_INTERVAL_MS, + ), + ), + stackflowAdminReadToken, + }; +} + +async function proxyToUpstream(args: { + request: IncomingMessage; + response: ServerResponse; + config: GatewayConfig; + proofHash: string; +}): Promise { + const { request, response, config, proofHash } = args; + const method = (request.method || 'GET').toUpperCase(); + const path = request.url || '/'; + const targetUrl = new URL(path, config.upstreamBaseUrl); + + const bodyAllowed = method !== 'GET' && method !== 'HEAD'; + const body = bodyAllowed ? await readBody(request) : undefined; + const headers = filterRequestHeaders(request, proofHash); + + const upstreamResponse = await fetch(targetUrl, { + method, + redirect: 'manual', + headers, + body, + signal: AbortSignal.timeout(config.upstreamTimeoutMs), + }); + + const responseBody = Buffer.from(await upstreamResponse.arrayBuffer()); + response.writeHead(upstreamResponse.status, filterResponseHeaders(upstreamResponse)); + response.end(responseBody); +} + +async function proxyWithoutPayment(args: { + request: IncomingMessage; + response: ServerResponse; + config: GatewayConfig; +}): Promise { + const { request, response, config } = args; + const method = (request.method || 'GET').toUpperCase(); + const path = request.url || '/'; + const targetUrl = new URL(path, config.upstreamBaseUrl); + const bodyAllowed = method !== 'GET' && method !== 'HEAD'; + const body = bodyAllowed ? await readBody(request) : undefined; + const headers = filterRequestHeaders(request, 'unpaid-route'); + delete headers['x-stackflow-x402-verified']; + delete headers['x-stackflow-x402-proof-hash']; + + const upstreamResponse = await fetch(targetUrl, { + method, + redirect: 'manual', + headers, + body, + signal: AbortSignal.timeout(config.upstreamTimeoutMs), + }); + + const responseBody = Buffer.from(await upstreamResponse.arrayBuffer()); + response.writeHead(upstreamResponse.status, filterResponseHeaders(upstreamResponse)); + response.end(responseBody); +} + +async function main(): Promise { + const config = loadConfig(); + const consumedProofs = new Map(); + + const pruneConsumedProofs = (): void => { + const now = Date.now(); + for (const [key, expiresAt] of consumedProofs.entries()) { + if (expiresAt <= now) { + consumedProofs.delete(key); + } + } + }; + + const server = http.createServer(async (request, response) => { + try { + pruneConsumedProofs(); + const method = (request.method || 'GET').toUpperCase(); + const url = new URL(request.url || '/', 'http://localhost'); + const routeBinding = `${url.pathname}${url.search}`; + + if (method === 'GET' && url.pathname === '/health') { + writeJson(response, 200, { ok: true, service: 'x402-gateway' }); + return; + } + + if (url.pathname !== config.protectedPath) { + await proxyWithoutPayment({ request, response, config }); + return; + } + + const paymentHeader = readHeaderValue(request, HEADER_X402_PAYMENT); + if (!paymentHeader) { + writePaymentRequired( + response, + config, + 'payment-header-missing', + `${HEADER_X402_PAYMENT} header is required`, + ); + return; + } + + let paymentProof: GatewayPaymentProof; + try { + const parsed = parsePaymentProofHeader(paymentHeader); + paymentProof = buildGatewayPaymentProof(parsed, config); + } catch (error) { + writePaymentRequired( + response, + config, + 'invalid-payment-proof', + error instanceof Error ? error.message : 'invalid payment proof', + ); + return; + } + + const proofHash = buildProofHash(method, routeBinding, paymentProof); + const consumedUntil = consumedProofs.get(proofHash); + if (typeof consumedUntil === 'number' && consumedUntil > Date.now()) { + writePaymentRequired( + response, + config, + 'payment-proof-already-used', + 'this payment proof has already been consumed for this route/method', + ); + return; + } + + if (paymentProof.mode === 'direct') { + const stackflowPayload = buildStackflowTransferPayload(paymentProof.proof); + const peer = buildPeerMetadata(request, proofHash, 'direct'); + const stackflowResult = await callStackflowCounterpartyTransfer({ + config, + payload: stackflowPayload, + peer, + }); + + const stackflowAccepted = + stackflowResult.statusCode >= 200 && + stackflowResult.statusCode < 300 && + stackflowResult.body.ok === true; + if (!stackflowAccepted) { + const stackflowReason = + typeof stackflowResult.body.reason === 'string' + ? stackflowResult.body.reason + : typeof stackflowResult.body.error === 'string' + ? stackflowResult.body.error + : 'unknown'; + writePaymentRequired( + response, + config, + 'payment-rejected', + `stackflow rejected direct proof (status=${stackflowResult.statusCode}, reason=${stackflowReason})`, + ); + return; + } + } else { + await waitForIndirectPayment({ + config, + proof: paymentProof, + }); + + const revealPeer = buildPeerMetadata(request, proofHash, 'indirect-reveal'); + const revealResult = await callStackflowForwardingReveal({ + config, + paymentId: paymentProof.paymentId, + secret: paymentProof.secret, + peer: revealPeer, + }); + const revealAccepted = + revealResult.statusCode >= 200 && + revealResult.statusCode < 300 && + revealResult.body.ok === true; + if (!revealAccepted) { + const revealReason = + typeof revealResult.body.reason === 'string' + ? revealResult.body.reason + : typeof revealResult.body.error === 'string' + ? revealResult.body.error + : 'unknown'; + writePaymentRequired( + response, + config, + 'payment-rejected', + `stackflow rejected indirect reveal (status=${revealResult.statusCode}, reason=${revealReason})`, + ); + return; + } + } + + await proxyToUpstream({ request, response, config, proofHash }); + consumedProofs.set(proofHash, Date.now() + config.proofReplayTtlMs); + } catch (error) { + const message = error instanceof Error ? error.message : 'gateway error'; + console.error(`[x402-gateway] request failed: ${message}`); + writeJson(response, 502, { + ok: false, + error: 'x402 gateway request failed', + details: message, + }); + } + }); + + server.listen(config.port, config.host, () => { + console.log( + `[x402-gateway] listening on http://${config.host}:${config.port} protected-path=${config.protectedPath} ` + + `price=${config.priceAmount} ${config.priceAsset} stackflow-node=${config.stackflowNodeBaseUrl} upstream=${config.upstreamBaseUrl} ` + + `indirect-wait-timeout-ms=${config.indirectWaitTimeoutMs} indirect-poll-interval-ms=${config.indirectPollIntervalMs}`, + ); + }); +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`[x402-gateway] fatal error: ${message}`); + process.exit(1); +}); From 7127b6a3b53667d1475ee605384631dbf1f9e3ae Mon Sep 17 00:00:00 2001 From: obycode Date: Tue, 3 Mar 2026 15:40:27 -0500 Subject: [PATCH 35/78] chore: remove unused `actor` parameter --- contracts/stackflow.clar | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index e700769..6f44567 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -828,7 +828,7 @@ ) (try! (balance-check pipe-key balance-1 balance-2 valid-after)) (try! (nonce-check pipe-key nonce)) - (try! (verify-hash-signature hash signature signer actor)) + (try! (verify-hash-signature hash signature signer)) (if (> after burn-block-height) (ok (some (- after burn-block-height))) (ok none) @@ -915,7 +915,7 @@ (try! (validate-transition pipe-key balance-1 balance-2 nonce action actor amount valid-after )) - (try! (verify-hash-signature hash signature signer actor)) + (try! (verify-hash-signature hash signature signer)) (if (> after burn-block-height) (ok (some (- after burn-block-height))) (ok none) @@ -959,10 +959,10 @@ ))) ) (try! (balance-check pipe-key balance-1 balance-2 valid-after)) - (unwrap! (verify-hash-signature hash signature-1 signer-1 actor) + (unwrap! (verify-hash-signature hash signature-1 signer-1) ERR_INVALID_SENDER_SIGNATURE ) - (unwrap! (verify-hash-signature hash signature-2 signer-2 actor) + (unwrap! (verify-hash-signature hash signature-2 signer-2) ERR_INVALID_OTHER_SIGNATURE ) (ok true) @@ -1294,7 +1294,6 @@ (hash (buff 32)) (signature (buff 65)) (signer principal) - (actor principal) ) (let ((recovered (unwrap! (principal-of? (unwrap! (secp256k1-recover? hash signature) ERR_INVALID_SIGNATURE)) From e64b83a31e64a25c95d17ed51a8755ee377de923 Mon Sep 17 00:00:00 2001 From: obycode Date: Tue, 3 Mar 2026 16:24:14 -0500 Subject: [PATCH 36/78] chore: use latest `stackflow-token` --- Clarinet.toml | 3 +++ contracts/stackflow.clar | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Clarinet.toml b/Clarinet.toml index 1268784..bba5047 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -8,6 +8,9 @@ cache_dir = './.cache' [[project.requirements]] contract_id = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard' +[[project.requirements]] +contract_id = "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token" + [contracts.reservoir] path = 'contracts/reservoir.clar' clarity_version = 4 diff --git a/contracts/stackflow.clar b/contracts/stackflow.clar index 6f44567..15bd754 100644 --- a/contracts/stackflow.clar +++ b/contracts/stackflow.clar @@ -29,8 +29,7 @@ ;; SOFTWARE. (use-trait sip-010 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) -;; (impl-trait 'SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token) -(impl-trait .stackflow-token.stackflow-token) +(impl-trait 'SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.stackflow-token) (define-constant CONTRACT_DEPLOYER tx-sender) (define-constant MAX_HEIGHT u340282366920938463463374607431768211455) From 027457f6df4de3e4e815b9dc969b1c95390abc05 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 4 Mar 2026 16:37:06 -0500 Subject: [PATCH 37/78] feat: add WIP x402 client SDK and stackflow agent scaffolds --- README.md | 23 + package.json | 1 + packages/stackflow-agent/README.md | 85 ++++ packages/stackflow-agent/package.json | 10 + packages/stackflow-agent/src/agent-service.js | 450 ++++++++++++++++++ packages/stackflow-agent/src/aibtc-adapter.js | 346 ++++++++++++++ packages/stackflow-agent/src/db.js | 402 ++++++++++++++++ packages/stackflow-agent/src/event-source.js | 80 ++++ packages/stackflow-agent/src/index.js | 15 + .../stackflow-agent/src/pipe-state-source.js | 36 ++ packages/stackflow-agent/src/utils.js | 177 +++++++ packages/stackflow-agent/src/watcher.js | 321 +++++++++++++ packages/x402-client/README.md | 117 +++++ packages/x402-client/package.json | 10 + packages/x402-client/src/client.js | 245 ++++++++++ packages/x402-client/src/index.js | 11 + packages/x402-client/src/pipe-state-source.js | 271 +++++++++++ .../x402-client/src/sqlite-state-store.js | 326 +++++++++++++ server/STACKFLOW_AGENT_DESIGN.md | 98 ++++ server/X402_CLIENT_SDK_DESIGN.md | 224 +++++++++ tests/stackflow-agent.test.ts | 292 ++++++++++++ tests/x402-client.test.ts | 261 ++++++++++ vitest.node.config.js | 15 + 23 files changed, 3816 insertions(+) create mode 100644 packages/stackflow-agent/README.md create mode 100644 packages/stackflow-agent/package.json create mode 100644 packages/stackflow-agent/src/agent-service.js create mode 100644 packages/stackflow-agent/src/aibtc-adapter.js create mode 100644 packages/stackflow-agent/src/db.js create mode 100644 packages/stackflow-agent/src/event-source.js create mode 100644 packages/stackflow-agent/src/index.js create mode 100644 packages/stackflow-agent/src/pipe-state-source.js create mode 100644 packages/stackflow-agent/src/utils.js create mode 100644 packages/stackflow-agent/src/watcher.js create mode 100644 packages/x402-client/README.md create mode 100644 packages/x402-client/package.json create mode 100644 packages/x402-client/src/client.js create mode 100644 packages/x402-client/src/index.js create mode 100644 packages/x402-client/src/pipe-state-source.js create mode 100644 packages/x402-client/src/sqlite-state-store.js create mode 100644 server/STACKFLOW_AGENT_DESIGN.md create mode 100644 server/X402_CLIENT_SDK_DESIGN.md create mode 100644 tests/stackflow-agent.test.ts create mode 100644 tests/x402-client.test.ts create mode 100644 vitest.node.config.js diff --git a/README.md b/README.md index c1b79d9..83eafbf 100644 --- a/README.md +++ b/README.md @@ -550,6 +550,9 @@ This repo includes a starter x402-style gateway at Detailed technical design and production guidance: - `server/X402_GATEWAY_DESIGN.md` +- `server/X402_CLIENT_SDK_DESIGN.md` +- `packages/x402-client/` (SDK scaffold with SQLite-backed client state store) +- `server/STACKFLOW_AGENT_DESIGN.md` (simple agent runtime without local node) Run it with: @@ -705,6 +708,26 @@ Current scaffold scope: 2. one-time proof consumption per method/path within replay TTL 3. single protected route configuration (expand to route policy map as next step) +## Agent Scaffold + +This repo includes an agent-first Stackflow scaffold at +`packages/stackflow-agent/` for deployments that do not run local +`stacks-node`/`stackflow-node`. + +It provides: + +1. SQLite persistence for tracked pipes and latest signatures +2. AIBTC MCP wallet adapter hooks for `sip018_sign`, `call_contract`, and + read-only `get-pipe` +3. hourly closure watcher (default `60 * 60 * 1000`) that polls tracked pipes + via read-only `get-pipe` and can auto-submit + disputes when a newer beneficial local signature state exists + +See: + +1. `packages/stackflow-agent/README.md` +2. `server/STACKFLOW_AGENT_DESIGN.md` + Integration tests for the HTTP server are opt-in (they spawn a real process and bind a local port): diff --git a/package.json b/package.json index 225389f..415d722 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "init:stackflow": "node scripts/init-stackflow.js", "build:ui": "node scripts/build-ui.js", "test": "vitest run", + "test:node-utils": "vitest run -c vitest.node.config.js tests/x402-client.test.ts tests/stackflow-agent.test.ts", "test:stackflow-node:http": "STACKFLOW_NODE_HTTP_INTEGRATION=1 vitest run tests/stackflow-node-http.integration.test.ts", "test:report": "vitest run -- --coverage --costs", "test:watch": "chokidar \"tests/**/*.ts\" \"contracts/**/*.clar\" -c \"npm run test:report\"", diff --git a/packages/stackflow-agent/README.md b/packages/stackflow-agent/README.md new file mode 100644 index 0000000..d7c22a9 --- /dev/null +++ b/packages/stackflow-agent/README.md @@ -0,0 +1,85 @@ +# @stackflow/agent (scaffold) + +Simple Stackflow agent runtime for AI agents that do **not** run `stacks-node` +or `stackflow-node`. + +Current model: + +1. SQLite local state for tracked pipes and latest signatures +2. signer + contract call adapter backed by AIBTC MCP wallet tools +3. hourly chain watcher to detect force-close/force-cancel and dispute + +## Runtime Components + +1. `AgentStateStore`: SQLite persistence +2. `StackflowAgentService`: pipe tracking + signature state + dispute logic +3. `AibtcWalletAdapter`: wrapper around AIBTC MCP tools +4. `HourlyClosureWatcher`: periodic closure scan (default every hour) + +## Example Wiring + +```js +import { + AgentStateStore, + AibtcPipeStateSource, + AibtcWalletAdapter, + HourlyClosureWatcher, + StackflowAgentService, +} from "./src/index.js"; + +const stateStore = new AgentStateStore({ + dbFile: "./tmp/stackflow-agent.db", +}); + +// You provide invokeTool using your MCP client runtime. +const wallet = new AibtcWalletAdapter({ + invokeTool: async (toolName, args) => { + // Example: + // return mcpClient.callTool({ name: toolName, arguments: args }); + throw new Error("implement invokeTool"); + }, +}); + +const agent = new StackflowAgentService({ + stateStore, + signer: wallet, + network: "devnet", + disputeOnlyBeneficial: true, +}); + +const pipeSource = new AibtcPipeStateSource({ + walletAdapter: wallet, + contractId: "ST...stackflow", + network: "devnet", +}); + +const watcher = new HourlyClosureWatcher({ + agentService: agent, + // Simpler mode: poll each tracked pipe via read-only `get-pipe`. + getPipeState: (args) => pipeSource.getPipeState(args), + intervalMs: 60 * 60 * 1000, // 1 hour +}); + +watcher.start(); +``` + +## Core Operations + +1. `trackPipe(...)` +2. `recordSignedState(...)` +3. `openPipe(...)` (via wallet `call_contract`) +4. `buildOutgoingTransfer(...)` +5. `validateIncomingTransfer(...)` +6. `acceptIncomingTransfer(...)` (validate + sign + persist) +7. `evaluateClosureForDispute(...)` +8. `disputeClosure(...)` +9. `watcher.runOnce()` or `watcher.start()` for hourly checks + +## Notes + +1. This scaffold intentionally avoids observer endpoints and local chain node. +2. The watcher interval defaults to one hour; dispute window is still 144 BTC blocks. +3. `HourlyClosureWatcher` supports two sources: + - `getPipeState` (recommended): per-pipe read-only polling (`get-pipe`) + - `listClosureEvents`: event scan mode +4. For production hardening, add alerting, signer balance checks, and idempotency audit logs. diff --git a/packages/stackflow-agent/package.json b/packages/stackflow-agent/package.json new file mode 100644 index 0000000..652aa9c --- /dev/null +++ b/packages/stackflow-agent/package.json @@ -0,0 +1,10 @@ +{ + "name": "@stackflow/agent", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/stackflow-agent/src/agent-service.js b/packages/stackflow-agent/src/agent-service.js new file mode 100644 index 0000000..f09f94d --- /dev/null +++ b/packages/stackflow-agent/src/agent-service.js @@ -0,0 +1,450 @@ +import { + buildDisputeCallInput, + buildPipeId, + isDisputeBeneficial, + normalizeHex, + normalizeClosureEvent, + parseUnsignedBigInt, + toUnsignedString, +} from "./utils.js"; + +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be non-empty`); + } + return text; +} + +function normalizeBoolean(value, fallback = false) { + if (value === undefined || value === null) { + return fallback; + } + return value === true; +} + +export class StackflowAgentService { + constructor({ + stateStore, + signer, + chainClient = null, + network = "devnet", + disputeOnlyBeneficial = true, + }) { + if (!stateStore) { + throw new Error("stateStore is required"); + } + if (!signer) { + throw new Error("signer is required"); + } + + this.stateStore = stateStore; + this.signer = signer; + this.chainClient = chainClient; + this.network = String(network || "devnet").trim(); + this.disputeOnlyBeneficial = normalizeBoolean(disputeOnlyBeneficial, true); + } + + trackPipe({ + contractId, + pipeKey, + localPrincipal, + counterpartyPrincipal, + token = null, + }) { + const pipeId = buildPipeId({ contractId, pipeKey }); + this.stateStore.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal, + counterpartyPrincipal, + token, + status: "open", + lastChainNonce: null, + }); + return { + pipeId, + contractId, + pipeKey, + localPrincipal, + counterpartyPrincipal, + token: token ?? null, + }; + } + + recordSignedState(input) { + return this.stateStore.upsertSignatureState(input); + } + + getTrackedPipes() { + return this.stateStore.listTrackedPipes(); + } + + getPipeLatestState({ pipeId, forPrincipal }) { + return this.stateStore.getLatestSignatureState(pipeId, forPrincipal); + } + + buildOutgoingTransfer({ + pipeId, + amount, + actor, + action = "1", + secret = null, + validAfter = null, + beneficialOnly = false, + baseMyBalance = null, + baseTheirBalance = null, + baseNonce = null, + }) { + const tracked = this.stateStore.getTrackedPipe(pipeId); + if (!tracked) { + throw new Error(`pipe is not tracked: ${pipeId}`); + } + + const latest = this.stateStore.getLatestSignatureState( + tracked.pipeId, + tracked.localPrincipal, + ); + const currentMy = latest + ? parseUnsignedBigInt(latest.myBalance, "latest.myBalance") + : parseUnsignedBigInt( + baseMyBalance ?? "0", + "baseMyBalance", + ); + const currentTheir = latest + ? parseUnsignedBigInt(latest.theirBalance, "latest.theirBalance") + : parseUnsignedBigInt( + baseTheirBalance ?? "0", + "baseTheirBalance", + ); + const currentNonce = latest + ? parseUnsignedBigInt(latest.nonce, "latest.nonce") + : parseUnsignedBigInt(baseNonce ?? "0", "baseNonce"); + + const transferAmount = parseUnsignedBigInt(amount, "amount"); + if (transferAmount <= 0n) { + throw new Error("amount must be > 0"); + } + if (currentMy < transferAmount) { + throw new Error("insufficient local balance for transfer"); + } + + const nextMy = currentMy - transferAmount; + const nextTheir = currentTheir + transferAmount; + const nextNonce = currentNonce + 1n; + + return { + contractId: tracked.contractId, + pipeKey: tracked.pipeKey, + forPrincipal: tracked.localPrincipal, + withPrincipal: tracked.counterpartyPrincipal, + token: tracked.token, + myBalance: nextMy.toString(10), + theirBalance: nextTheir.toString(10), + nonce: nextNonce.toString(10), + action: toUnsignedString(action, "action"), + actor: assertNonEmptyString(actor, "actor"), + secret, + validAfter, + beneficialOnly: beneficialOnly === true, + }; + } + + validateIncomingTransfer({ pipeId, payload }) { + const tracked = this.stateStore.getTrackedPipe(pipeId); + if (!tracked) { + return { + valid: false, + reason: "pipe-not-tracked", + }; + } + const data = payload && typeof payload === "object" ? payload : null; + if (!data) { + return { + valid: false, + reason: "payload-invalid", + }; + } + const contractId = String(data.contractId ?? tracked.contractId).trim(); + if (contractId !== tracked.contractId) { + return { + valid: false, + reason: "contract-mismatch", + }; + } + const forPrincipal = String(data.forPrincipal ?? "").trim(); + if (forPrincipal !== tracked.localPrincipal) { + return { + valid: false, + reason: "for-principal-mismatch", + }; + } + const withPrincipal = String(data.withPrincipal ?? "").trim(); + if (withPrincipal !== tracked.counterpartyPrincipal) { + return { + valid: false, + reason: "with-principal-mismatch", + }; + } + const theirSignature = (() => { + try { + return normalizeHex(data.theirSignature, "theirSignature"); + } catch { + return null; + } + })(); + if (!theirSignature) { + return { + valid: false, + reason: "missing-or-invalid-their-signature", + }; + } + let nonce; + let action; + let myBalance; + let theirBalance; + try { + nonce = toUnsignedString(data.nonce, "nonce"); + action = toUnsignedString(data.action ?? "1", "action"); + myBalance = toUnsignedString(data.myBalance, "myBalance"); + theirBalance = toUnsignedString(data.theirBalance, "theirBalance"); + } catch (error) { + return { + valid: false, + reason: error instanceof Error ? error.message : "invalid-payload", + }; + } + const actor = String(data.actor ?? "").trim(); + if (!actor) { + return { + valid: false, + reason: "actor-missing", + }; + } + const latest = this.stateStore.getLatestSignatureState( + tracked.pipeId, + tracked.localPrincipal, + ); + if (latest) { + const existingNonce = parseUnsignedBigInt(latest.nonce, "existing nonce"); + const incomingNonce = parseUnsignedBigInt(nonce, "incoming nonce"); + if (incomingNonce <= existingNonce) { + return { + valid: false, + reason: "nonce-too-low", + existingNonce: latest.nonce, + }; + } + } + + let secret = null; + try { + secret = data.secret == null ? null : normalizeHex(data.secret, "secret"); + } catch (error) { + return { + valid: false, + reason: error instanceof Error ? error.message : "invalid-secret", + }; + } + + return { + valid: true, + state: { + contractId, + pipeId: tracked.pipeId, + pipeKey: tracked.pipeKey, + forPrincipal, + withPrincipal, + token: data.token == null ? tracked.token : String(data.token).trim(), + myBalance, + theirBalance, + nonce, + action, + actor, + mySignature: null, + theirSignature, + secret, + validAfter: + data.validAfter == null + ? null + : toUnsignedString(data.validAfter, "validAfter"), + beneficialOnly: data.beneficialOnly === true, + }, + }; + } + + async signTransferMessage({ + contractId, + message, + walletPassword = null, + }) { + if (typeof this.signer.sip018Sign !== "function") { + throw new Error("signer.sip018Sign is required"); + } + return this.signer.sip018Sign({ + contract: contractId, + message, + walletPassword, + }); + } + + async acceptIncomingTransfer({ + pipeId, + payload, + walletPassword = null, + }) { + const validation = this.validateIncomingTransfer({ + pipeId, + payload, + }); + if (!validation.valid) { + return { + accepted: false, + ...validation, + }; + } + + const state = validation.state; + const mySignature = await this.signTransferMessage({ + contractId: state.contractId, + message: { + "pipe-key": state.pipeKey, + "balance-1": state.myBalance, + "balance-2": state.theirBalance, + nonce: state.nonce, + action: state.action, + actor: state.actor, + "hashed-secret": state.secret, + "valid-after": state.validAfter, + }, + walletPassword, + }); + + const upsert = this.stateStore.upsertSignatureState({ + ...state, + mySignature, + }); + + return { + accepted: true, + mySignature, + upsert, + state, + }; + } + + buildOpenPipeCall({ + contractId, + token = null, + amount, + counterpartyPrincipal, + nonce = "0", + }) { + return { + contractId: assertNonEmptyString(contractId, "contractId"), + functionName: "fund-pipe", + functionArgs: [ + token, + toUnsignedString(amount, "amount"), + assertNonEmptyString(counterpartyPrincipal, "counterpartyPrincipal"), + toUnsignedString(nonce, "nonce"), + ], + }; + } + + async openPipe(args) { + const call = this.buildOpenPipeCall(args); + return this.signer.callContract({ + contractId: call.contractId, + functionName: call.functionName, + functionArgs: call.functionArgs, + network: this.network, + }); + } + + evaluateClosureForDispute(event) { + const closure = normalizeClosureEvent(event); + const trackedPipe = this.stateStore.getTrackedPipe(closure.pipeId); + if (!trackedPipe) { + return { + closure, + tracked: false, + shouldDispute: false, + reason: "pipe-not-tracked", + }; + } + + const latestState = this.stateStore.getLatestSignatureState( + closure.pipeId, + trackedPipe.localPrincipal, + ); + if (!latestState) { + return { + closure, + tracked: true, + shouldDispute: false, + reason: "no-local-signature-state", + }; + } + + const shouldDispute = isDisputeBeneficial({ + closureEvent: closure, + signatureState: latestState, + onlyBeneficial: this.disputeOnlyBeneficial, + }); + + return { + closure, + tracked: true, + shouldDispute, + reason: shouldDispute ? "eligible" : "not-beneficial-or-stale", + latestState, + }; + } + + async disputeClosure({ + closureEvent, + walletPassword = null, + }) { + const decision = this.evaluateClosureForDispute(closureEvent); + if (!decision.shouldDispute) { + return { + submitted: false, + reason: decision.reason, + decision, + }; + } + + const result = await this.signer.submitDispute({ + closureEvent: decision.closure, + signatureState: decision.latestState, + network: this.network, + walletPassword, + }); + + const disputeTxid = + typeof result.txid === "string" + ? result.txid + : typeof result.data?.txid === "string" + ? result.data.txid + : null; + if (disputeTxid) { + this.stateStore.markClosureDisputed({ + txid: decision.closure.txid, + disputeTxid, + }); + } + + return { + submitted: true, + disputeTxid, + callInput: buildDisputeCallInput({ + closureEvent: decision.closure, + signatureState: decision.latestState, + }), + decision, + raw: result, + }; + } +} diff --git a/packages/stackflow-agent/src/aibtc-adapter.js b/packages/stackflow-agent/src/aibtc-adapter.js new file mode 100644 index 0000000..41fd3ba --- /dev/null +++ b/packages/stackflow-agent/src/aibtc-adapter.js @@ -0,0 +1,346 @@ +import { buildDisputeCallInput } from "./utils.js"; + +function assertFunction(value, fieldName) { + if (typeof value !== "function") { + throw new Error(`${fieldName} must be a function`); + } + return value; +} + +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be non-empty`); + } + return text; +} + +function asRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} + +function parseReadOnlyError(result) { + const record = asRecord(result); + if (!record) { + return null; + } + const kind = typeof record.type === "string" ? record.type.toLowerCase() : null; + if (kind === "responseerr" || kind === "response_err" || kind === "err") { + return `readonly call returned error response`; + } + return null; +} + +function unwrapReadonlyValue(input) { + if (input == null) { + return null; + } + if (typeof input === "string" || typeof input === "number" || typeof input === "boolean") { + return input; + } + if (Array.isArray(input)) { + return input.map((entry) => unwrapReadonlyValue(entry)); + } + const record = asRecord(input); + if (!record) { + return null; + } + + const kind = typeof record.type === "string" ? record.type.toLowerCase() : null; + if (kind === "responseok" || kind === "response_ok" || kind === "ok") { + return unwrapReadonlyValue(record.value); + } + if (kind === "responseerr" || kind === "response_err" || kind === "err") { + throw new Error("readonly call returned error response"); + } + if (kind === "none" || kind === "optionalnone" || kind === "optional_none") { + return null; + } + if (kind === "some" || kind === "optionalsome" || kind === "optional_some") { + return unwrapReadonlyValue(record.value); + } + if (kind === "uint" || kind === "int") { + return String(record.value ?? ""); + } + if (kind === "tuple" && record.value && typeof record.value === "object") { + return unwrapReadonlyValue(record.value); + } + + if (Object.prototype.hasOwnProperty.call(record, "value")) { + return unwrapReadonlyValue(record.value); + } + + const output = {}; + for (const [key, value] of Object.entries(record)) { + output[key] = unwrapReadonlyValue(value); + } + return output; +} + +function extractPipePayload(rawResult) { + const record = asRecord(rawResult); + if (!record) { + return null; + } + + const direct = unwrapReadonlyValue(rawResult); + if (direct && typeof direct === "object" && !Array.isArray(direct)) { + const directRecord = direct; + if ( + Object.prototype.hasOwnProperty.call(directRecord, "balance-1") || + Object.prototype.hasOwnProperty.call(directRecord, "balance1") + ) { + return directRecord; + } + } + + const candidates = [ + record.pipe, + record.value, + record.result, + record.data, + asRecord(record.data)?.pipe, + asRecord(record.data)?.value, + asRecord(record.data)?.result, + ]; + + for (const candidate of candidates) { + const parsed = unwrapReadonlyValue(candidate); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + continue; + } + if ( + Object.prototype.hasOwnProperty.call(parsed, "balance-1") || + Object.prototype.hasOwnProperty.call(parsed, "balance1") + ) { + return parsed; + } + } + return null; +} + +function isMissingToolError(error) { + const message = String(error instanceof Error ? error.message : error).toLowerCase(); + return ( + message.includes("unknown tool") || + message.includes("tool not found") || + message.includes("not found") || + message.includes("no such tool") + ); +} + +function normalizeToolResult(result, toolName) { + if (!result || typeof result !== "object") { + throw new Error(`${toolName} returned an invalid response`); + } + return result; +} + +export class AibtcWalletAdapter { + constructor({ + invokeTool, + readonlyToolName = null, + }) { + this.invokeTool = assertFunction(invokeTool, "invokeTool"); + this.readonlyToolName = + readonlyToolName == null ? null : assertNonEmptyString(readonlyToolName, "readonlyToolName"); + } + + async sip018Sign({ + contract, + message, + walletPassword = null, + }) { + const result = normalizeToolResult( + await this.invokeTool("sip018_sign", { + contract, + message, + wallet_password: walletPassword ?? undefined, + }), + "sip018_sign", + ); + + const signature = result.signature ?? result.data?.signature ?? null; + if (typeof signature !== "string" || !signature.trim()) { + throw new Error("sip018_sign did not return a signature"); + } + return signature; + } + + async callContract({ + contractId, + functionName, + functionArgs, + network = null, + walletPassword = null, + postConditions = null, + postConditionMode = null, + }) { + const [contractAddress, contractName] = String(contractId).split("."); + if (!contractAddress || !contractName) { + throw new Error("contractId must be
."); + } + + const result = normalizeToolResult( + await this.invokeTool("call_contract", { + contractAddress, + contractName, + functionName, + functionArgs, + network: network ?? undefined, + wallet_password: walletPassword ?? undefined, + postConditions: postConditions ?? undefined, + postConditionMode: postConditionMode ?? undefined, + }), + "call_contract", + ); + + return result; + } + + async submitDispute({ + closureEvent, + signatureState, + network = null, + walletPassword = null, + }) { + const disputeInput = buildDisputeCallInput({ + closureEvent, + signatureState, + }); + return this.callContract({ + contractId: disputeInput.contractId, + functionName: disputeInput.functionName, + functionArgs: disputeInput.functionArgs, + network, + walletPassword, + postConditionMode: "allow", + }); + } + + async getContractEvents({ + contractId, + fromHeight = null, + toHeight = null, + limit = 200, + offset = 0, + network = null, + }) { + const result = normalizeToolResult( + await this.invokeTool("get_contract_events", { + contract_id: contractId, + from_height: fromHeight ?? undefined, + to_height: toHeight ?? undefined, + limit, + offset, + network: network ?? undefined, + }), + "get_contract_events", + ); + + const events = Array.isArray(result.events) + ? result.events + : Array.isArray(result.data?.events) + ? result.data.events + : []; + + return { + events, + nextOffset: + typeof result.nextOffset === "number" + ? result.nextOffset + : typeof result.data?.nextOffset === "number" + ? result.data.nextOffset + : null, + }; + } + + async callReadonly({ + contractId, + functionName, + functionArgs, + sender, + network = null, + }) { + const [contractAddress, contractName] = String(contractId).split("."); + if (!contractAddress || !contractName) { + throw new Error("contractId must be
."); + } + const senderPrincipal = assertNonEmptyString(sender, "sender"); + const toolArgs = { + contractAddress, + contractName, + functionName: assertNonEmptyString(functionName, "functionName"), + functionArgs: Array.isArray(functionArgs) ? functionArgs : [], + sender: senderPrincipal, + sender_address: senderPrincipal, + network: network ?? undefined, + }; + + if (this.readonlyToolName) { + const direct = normalizeToolResult( + await this.invokeTool(this.readonlyToolName, toolArgs), + this.readonlyToolName, + ); + const readOnlyError = parseReadOnlyError(direct); + if (readOnlyError) { + throw new Error(readOnlyError); + } + return direct; + } + + const toolNames = [ + "call_readonly", + "call_read_only", + "call_readonly_function", + "call_read_only_function", + "call_contract_readonly", + "call_contract_read_only", + ]; + let lastError = null; + for (const toolName of toolNames) { + try { + const result = normalizeToolResult( + await this.invokeTool(toolName, toolArgs), + toolName, + ); + const readOnlyError = parseReadOnlyError(result); + if (readOnlyError) { + throw new Error(readOnlyError); + } + return result; + } catch (error) { + lastError = error; + if (!isMissingToolError(error)) { + throw error; + } + } + } + + throw new Error( + `no supported readonly tool found; tried ${toolNames.join(", ")}${ + lastError ? ` (${String(lastError)})` : "" + }`, + ); + } + + async getPipe({ + contractId, + token = null, + forPrincipal, + withPrincipal, + network = null, + }) { + const result = await this.callReadonly({ + contractId, + functionName: "get-pipe", + functionArgs: [token, assertNonEmptyString(withPrincipal, "withPrincipal")], + sender: assertNonEmptyString(forPrincipal, "forPrincipal"), + network, + }); + return extractPipePayload(result); + } +} diff --git a/packages/stackflow-agent/src/db.js b/packages/stackflow-agent/src/db.js new file mode 100644 index 0000000..1b58064 --- /dev/null +++ b/packages/stackflow-agent/src/db.js @@ -0,0 +1,402 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { + normalizeClosureEvent, + normalizeSignatureState, + parseUnsignedBigInt, +} from "./utils.js"; + +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be non-empty`); + } + return text; +} + +function assertPositiveInt(value, fieldName) { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive integer`); + } + return parsed; +} + +export class AgentStateStore { + constructor({ dbFile, busyTimeoutMs = 5_000 }) { + this.dbFile = assertNonEmptyString(dbFile, "dbFile"); + this.busyTimeoutMs = assertPositiveInt(busyTimeoutMs, "busyTimeoutMs"); + const dir = path.dirname(this.dbFile); + fs.mkdirSync(dir, { recursive: true }); + this.db = new DatabaseSync(this.dbFile); + this.db.exec("PRAGMA journal_mode = WAL;"); + this.db.exec("PRAGMA synchronous = NORMAL;"); + this.db.exec(`PRAGMA busy_timeout = ${this.busyTimeoutMs};`); + + this.db.exec(` + CREATE TABLE IF NOT EXISTS tracked_pipes ( + pipe_id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + pipe_key_json TEXT NOT NULL, + local_principal TEXT NOT NULL, + counterparty_principal TEXT NOT NULL, + token TEXT, + status TEXT NOT NULL DEFAULT 'open', + last_chain_nonce TEXT, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS signature_states ( + state_id TEXT PRIMARY KEY, + pipe_id TEXT NOT NULL, + contract_id TEXT NOT NULL, + pipe_key_json TEXT NOT NULL, + for_principal TEXT NOT NULL, + with_principal TEXT NOT NULL, + token TEXT, + my_balance TEXT NOT NULL, + their_balance TEXT NOT NULL, + nonce TEXT NOT NULL, + action TEXT NOT NULL, + actor TEXT NOT NULL, + my_signature TEXT NOT NULL, + their_signature TEXT NOT NULL, + secret TEXT, + valid_after TEXT, + beneficial_only INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_signature_states_pipe_for_nonce + ON signature_states(pipe_id, for_principal, nonce); + + CREATE TABLE IF NOT EXISTS closures ( + txid TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + pipe_id TEXT NOT NULL, + pipe_key_json TEXT NOT NULL, + event_name TEXT NOT NULL, + nonce TEXT NOT NULL, + closer TEXT NOT NULL, + block_height TEXT NOT NULL, + expires_at TEXT NOT NULL, + closure_my_balance TEXT, + disputed INTEGER NOT NULL DEFAULT 0, + dispute_txid TEXT, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS watcher_cursor ( + id INTEGER PRIMARY KEY CHECK (id = 1), + last_block_height TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + INSERT INTO watcher_cursor (id, last_block_height, updated_at) + VALUES (1, '0', datetime('now')) + ON CONFLICT(id) DO NOTHING; + `); + + this.upsertPipeStmt = this.db.prepare(` + INSERT INTO tracked_pipes ( + pipe_id, + contract_id, + pipe_key_json, + local_principal, + counterparty_principal, + token, + status, + last_chain_nonce, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pipe_id) DO UPDATE SET + contract_id = excluded.contract_id, + pipe_key_json = excluded.pipe_key_json, + local_principal = excluded.local_principal, + counterparty_principal = excluded.counterparty_principal, + token = excluded.token, + status = excluded.status, + last_chain_nonce = excluded.last_chain_nonce, + updated_at = excluded.updated_at + `); + this.listTrackedPipesStmt = this.db.prepare(` + SELECT * FROM tracked_pipes ORDER BY updated_at DESC + `); + this.getTrackedPipeStmt = this.db.prepare(` + SELECT * FROM tracked_pipes WHERE pipe_id = ? + `); + + this.upsertSignatureStateStmt = this.db.prepare(` + INSERT INTO signature_states ( + state_id, + pipe_id, + contract_id, + pipe_key_json, + for_principal, + with_principal, + token, + my_balance, + their_balance, + nonce, + action, + actor, + my_signature, + their_signature, + secret, + valid_after, + beneficial_only, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(state_id) DO UPDATE SET + my_balance = excluded.my_balance, + their_balance = excluded.their_balance, + nonce = excluded.nonce, + action = excluded.action, + actor = excluded.actor, + my_signature = excluded.my_signature, + their_signature = excluded.their_signature, + secret = excluded.secret, + valid_after = excluded.valid_after, + beneficial_only = excluded.beneficial_only, + updated_at = excluded.updated_at + `); + this.getLatestSignatureStateStmt = this.db.prepare(` + SELECT * FROM signature_states + WHERE pipe_id = ? + AND for_principal = ? + ORDER BY CAST(nonce as INTEGER) DESC, updated_at DESC + LIMIT 1 + `); + + this.insertClosureStmt = this.db.prepare(` + INSERT INTO closures ( + txid, + contract_id, + pipe_id, + pipe_key_json, + event_name, + nonce, + closer, + block_height, + expires_at, + closure_my_balance, + disputed, + dispute_txid, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(txid) DO UPDATE SET + closure_my_balance = excluded.closure_my_balance, + disputed = MAX(closures.disputed, excluded.disputed), + dispute_txid = COALESCE(closures.dispute_txid, excluded.dispute_txid) + `); + this.markClosureDisputedStmt = this.db.prepare(` + UPDATE closures + SET disputed = 1, + dispute_txid = ? + WHERE txid = ? + `); + + this.getCursorStmt = this.db.prepare(` + SELECT last_block_height FROM watcher_cursor WHERE id = 1 + `); + this.setCursorStmt = this.db.prepare(` + UPDATE watcher_cursor + SET last_block_height = ?, + updated_at = ? + WHERE id = 1 + `); + } + + close() { + if (this.db) { + this.db.close(); + this.db = null; + } + } + + assertOpen() { + if (!this.db) { + throw new Error("state store is closed"); + } + } + + upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal, + counterpartyPrincipal, + token = null, + status = "open", + lastChainNonce = null, + }) { + this.assertOpen(); + this.upsertPipeStmt.run( + assertNonEmptyString(pipeId, "pipeId"), + assertNonEmptyString(contractId, "contractId"), + JSON.stringify(pipeKey), + assertNonEmptyString(localPrincipal, "localPrincipal"), + assertNonEmptyString(counterpartyPrincipal, "counterpartyPrincipal"), + token ? String(token).trim() : null, + String(status || "open"), + lastChainNonce == null ? null : String(lastChainNonce), + new Date().toISOString(), + ); + } + + listTrackedPipes() { + this.assertOpen(); + const rows = this.listTrackedPipesStmt.all(); + return rows.map((row) => ({ + pipeId: row.pipe_id, + contractId: row.contract_id, + pipeKey: JSON.parse(row.pipe_key_json), + localPrincipal: row.local_principal, + counterpartyPrincipal: row.counterparty_principal, + token: row.token, + status: row.status, + lastChainNonce: row.last_chain_nonce, + updatedAt: row.updated_at, + })); + } + + getTrackedPipe(pipeId) { + this.assertOpen(); + const row = this.getTrackedPipeStmt.get(assertNonEmptyString(pipeId, "pipeId")); + if (!row) { + return null; + } + return { + pipeId: row.pipe_id, + contractId: row.contract_id, + pipeKey: JSON.parse(row.pipe_key_json), + localPrincipal: row.local_principal, + counterpartyPrincipal: row.counterparty_principal, + token: row.token, + status: row.status, + lastChainNonce: row.last_chain_nonce, + updatedAt: row.updated_at, + }; + } + + upsertSignatureState(input) { + this.assertOpen(); + const state = normalizeSignatureState(input); + const stateId = `${state.pipeId}|${state.forPrincipal}`; + + const existing = this.getLatestSignatureState(state.pipeId, state.forPrincipal); + if (existing) { + const existingNonce = parseUnsignedBigInt(existing.nonce, "existing nonce"); + const incomingNonce = parseUnsignedBigInt(state.nonce, "incoming nonce"); + if (incomingNonce < existingNonce) { + return { + stored: false, + reason: "nonce-too-low", + state: existing, + }; + } + } + + this.upsertSignatureStateStmt.run( + stateId, + state.pipeId, + state.contractId, + JSON.stringify(state.pipeKey), + state.forPrincipal, + state.withPrincipal, + state.token, + state.myBalance, + state.theirBalance, + state.nonce, + state.action, + state.actor, + state.mySignature, + state.theirSignature, + state.secret, + state.validAfter, + state.beneficialOnly ? 1 : 0, + state.updatedAt, + ); + + return { + stored: true, + reason: existing ? "replaced" : "stored", + state, + }; + } + + getLatestSignatureState(pipeId, forPrincipal) { + this.assertOpen(); + const row = this.getLatestSignatureStateStmt.get( + assertNonEmptyString(pipeId, "pipeId"), + assertNonEmptyString(forPrincipal, "forPrincipal"), + ); + if (!row) { + return null; + } + return { + pipeId: row.pipe_id, + contractId: row.contract_id, + pipeKey: JSON.parse(row.pipe_key_json), + forPrincipal: row.for_principal, + withPrincipal: row.with_principal, + token: row.token, + myBalance: row.my_balance, + theirBalance: row.their_balance, + nonce: row.nonce, + action: row.action, + actor: row.actor, + mySignature: row.my_signature, + theirSignature: row.their_signature, + secret: row.secret, + validAfter: row.valid_after, + beneficialOnly: row.beneficial_only === 1, + updatedAt: row.updated_at, + }; + } + + recordClosure(event) { + this.assertOpen(); + const closure = normalizeClosureEvent(event); + this.insertClosureStmt.run( + closure.txid, + closure.contractId, + closure.pipeId, + JSON.stringify(closure.pipeKey), + closure.eventName, + closure.nonce, + closure.closer, + closure.blockHeight, + closure.expiresAt, + closure.closureMyBalance ?? null, + 0, + null, + new Date().toISOString(), + ); + return closure; + } + + markClosureDisputed({ txid, disputeTxid }) { + this.assertOpen(); + this.markClosureDisputedStmt.run( + assertNonEmptyString(disputeTxid, "disputeTxid"), + assertNonEmptyString(txid, "txid"), + ); + } + + getWatcherCursor() { + this.assertOpen(); + const row = this.getCursorStmt.get(); + return row ? row.last_block_height : "0"; + } + + setWatcherCursor(blockHeight) { + this.assertOpen(); + this.setCursorStmt.run( + String(blockHeight), + new Date().toISOString(), + ); + } +} diff --git a/packages/stackflow-agent/src/event-source.js b/packages/stackflow-agent/src/event-source.js new file mode 100644 index 0000000..d7c464d --- /dev/null +++ b/packages/stackflow-agent/src/event-source.js @@ -0,0 +1,80 @@ +import { normalizeClosureEvent } from "./utils.js"; + +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be non-empty`); + } + return text; +} + +function assertFunction(value, fieldName) { + if (typeof value !== "function") { + throw new Error(`${fieldName} must be a function`); + } + return value; +} + +export class AibtcClosureEventSource { + constructor({ + walletAdapter, + contractId, + network = "devnet", + decodeEvent, + }) { + if (!walletAdapter || typeof walletAdapter.getContractEvents !== "function") { + throw new Error("walletAdapter.getContractEvents is required"); + } + this.walletAdapter = walletAdapter; + this.contractId = assertNonEmptyString(contractId, "contractId"); + this.network = String(network || "devnet").trim(); + this.decodeEvent = assertFunction(decodeEvent, "decodeEvent"); + } + + async listClosureEvents({ + fromBlockHeight, + toBlockHeight = null, + pageSize = 200, + maxPages = 10, + }) { + const closures = []; + let offset = 0; + let pages = 0; + + while (pages < maxPages) { + const page = await this.walletAdapter.getContractEvents({ + contractId: this.contractId, + fromHeight: fromBlockHeight, + toHeight: toBlockHeight, + limit: pageSize, + offset, + network: this.network, + }); + const events = Array.isArray(page.events) ? page.events : []; + if (events.length === 0) { + break; + } + + for (const event of events) { + const decoded = this.decodeEvent(event); + if (!decoded) { + continue; + } + try { + const closure = normalizeClosureEvent(decoded); + closures.push(closure); + } catch { + // Ignore malformed/non-closure events. + } + } + + if (events.length < pageSize) { + break; + } + offset += pageSize; + pages += 1; + } + + return closures; + } +} diff --git a/packages/stackflow-agent/src/index.js b/packages/stackflow-agent/src/index.js new file mode 100644 index 0000000..04b6531 --- /dev/null +++ b/packages/stackflow-agent/src/index.js @@ -0,0 +1,15 @@ +export { AibtcWalletAdapter } from "./aibtc-adapter.js"; +export { StackflowAgentService } from "./agent-service.js"; +export { AgentStateStore } from "./db.js"; +export { AibtcClosureEventSource } from "./event-source.js"; +export { AibtcPipeStateSource } from "./pipe-state-source.js"; +export { HourlyClosureWatcher } from "./watcher.js"; +export { + buildDisputeCallInput, + buildPipeId, + isDisputeBeneficial, + normalizeClosureEvent, + normalizeSignatureState, + parseUnsignedBigInt, + toUnsignedString, +} from "./utils.js"; diff --git a/packages/stackflow-agent/src/pipe-state-source.js b/packages/stackflow-agent/src/pipe-state-source.js new file mode 100644 index 0000000..1883b57 --- /dev/null +++ b/packages/stackflow-agent/src/pipe-state-source.js @@ -0,0 +1,36 @@ +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be non-empty`); + } + return text; +} + +export class AibtcPipeStateSource { + constructor({ + walletAdapter, + contractId, + network = "devnet", + }) { + if (!walletAdapter || typeof walletAdapter.getPipe !== "function") { + throw new Error("walletAdapter.getPipe is required"); + } + this.walletAdapter = walletAdapter; + this.contractId = assertNonEmptyString(contractId, "contractId"); + this.network = String(network || "devnet").trim(); + } + + async getPipeState({ + token = null, + forPrincipal, + withPrincipal, + }) { + return this.walletAdapter.getPipe({ + contractId: this.contractId, + token, + forPrincipal, + withPrincipal, + network: this.network, + }); + } +} diff --git a/packages/stackflow-agent/src/utils.js b/packages/stackflow-agent/src/utils.js new file mode 100644 index 0000000..1a4b4f6 --- /dev/null +++ b/packages/stackflow-agent/src/utils.js @@ -0,0 +1,177 @@ +function assertObject(value, fieldName) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${fieldName} must be an object`); + } +} + +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be a non-empty string`); + } + return text; +} + +export function parseUnsignedBigInt(value, fieldName = "value") { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) { + throw new Error(`${fieldName} must be an unsigned integer string`); + } + return BigInt(text); +} + +export function toUnsignedString(value, fieldName = "value") { + if (typeof value === "bigint") { + if (value < 0n) { + throw new Error(`${fieldName} must be non-negative`); + } + return value.toString(10); + } + return parseUnsignedBigInt(value, fieldName).toString(10); +} + +export function normalizeHex(value, fieldName = "value") { + const text = String(value ?? "").trim().toLowerCase(); + const normalized = text.startsWith("0x") ? text : `0x${text}`; + if (!/^0x[0-9a-f]+$/.test(normalized)) { + throw new Error(`${fieldName} must be hex`); + } + return normalized; +} + +export function buildPipeId({ contractId, pipeKey }) { + const contract = assertNonEmptyString(contractId, "contractId"); + assertObject(pipeKey, "pipeKey"); + const principal1 = assertNonEmptyString(pipeKey["principal-1"], "pipeKey.principal-1"); + const principal2 = assertNonEmptyString(pipeKey["principal-2"], "pipeKey.principal-2"); + const token = pipeKey.token ? String(pipeKey.token).trim() : "stx"; + return `${contract}|${token}|${principal1}|${principal2}`; +} + +export function normalizeClosureEvent(event) { + assertObject(event, "closure event"); + const eventName = assertNonEmptyString(event.eventName, "eventName"); + if (eventName !== "force-cancel" && eventName !== "force-close") { + throw new Error("closure event must be force-cancel or force-close"); + } + + const contractId = assertNonEmptyString(event.contractId, "contractId"); + assertObject(event.pipeKey, "pipeKey"); + const pipeId = buildPipeId({ + contractId, + pipeKey: event.pipeKey, + }); + + const nonce = toUnsignedString(event.nonce ?? event.pipeNonce ?? "0", "nonce"); + const closer = assertNonEmptyString(event.closer, "closer"); + const txid = assertNonEmptyString(event.txid, "txid"); + const blockHeight = toUnsignedString(event.blockHeight, "blockHeight"); + const expiresAt = toUnsignedString(event.expiresAt, "expiresAt"); + const closureMyBalance = + event.closureMyBalance == null + ? null + : toUnsignedString(event.closureMyBalance, "closureMyBalance"); + + return { + contractId, + pipeId, + pipeKey: event.pipeKey, + eventName, + nonce, + closer, + txid, + blockHeight, + expiresAt, + closureMyBalance, + }; +} + +export function normalizeSignatureState(input) { + assertObject(input, "signature state"); + const contractId = assertNonEmptyString(input.contractId, "contractId"); + assertObject(input.pipeKey, "pipeKey"); + const pipeId = buildPipeId({ + contractId, + pipeKey: input.pipeKey, + }); + return { + contractId, + pipeId, + pipeKey: input.pipeKey, + forPrincipal: assertNonEmptyString(input.forPrincipal, "forPrincipal"), + withPrincipal: assertNonEmptyString(input.withPrincipal, "withPrincipal"), + token: input.token ? String(input.token).trim() : null, + myBalance: toUnsignedString(input.myBalance, "myBalance"), + theirBalance: toUnsignedString(input.theirBalance, "theirBalance"), + nonce: toUnsignedString(input.nonce, "nonce"), + action: toUnsignedString(input.action ?? "1", "action"), + actor: assertNonEmptyString(input.actor, "actor"), + mySignature: normalizeHex(input.mySignature, "mySignature"), + theirSignature: normalizeHex(input.theirSignature, "theirSignature"), + secret: input.secret == null ? null : normalizeHex(input.secret, "secret"), + validAfter: + input.validAfter == null ? null : toUnsignedString(input.validAfter, "validAfter"), + beneficialOnly: input.beneficialOnly === true, + updatedAt: input.updatedAt ? String(input.updatedAt) : new Date().toISOString(), + }; +} + +export function isDisputeBeneficial({ closureEvent, signatureState, onlyBeneficial }) { + if (!closureEvent || !signatureState) { + return false; + } + + const closureNonce = parseUnsignedBigInt(closureEvent.nonce, "closure nonce"); + const stateNonce = parseUnsignedBigInt(signatureState.nonce, "state nonce"); + if (stateNonce <= closureNonce) { + return false; + } + + if (!onlyBeneficial && !signatureState.beneficialOnly) { + return true; + } + + if (closureEvent.closer === signatureState.forPrincipal) { + return false; + } + + if (closureEvent.eventName === "force-cancel") { + return parseUnsignedBigInt(signatureState.myBalance, "myBalance") > 0n; + } + + if (!closureEvent.closureMyBalance) { + return true; + } + + const closureBalance = parseUnsignedBigInt( + closureEvent.closureMyBalance, + "closureMyBalance", + ); + const stateBalance = parseUnsignedBigInt(signatureState.myBalance, "myBalance"); + return stateBalance > closureBalance; +} + +export function buildDisputeCallInput({ closureEvent, signatureState }) { + if (!closureEvent || !signatureState) { + throw new Error("closureEvent and signatureState are required"); + } + + return { + contractId: closureEvent.contractId, + functionName: "dispute-closure-for", + functionArgs: [ + signatureState.forPrincipal, + signatureState.token, + signatureState.withPrincipal, + signatureState.myBalance, + signatureState.theirBalance, + signatureState.mySignature, + signatureState.theirSignature, + signatureState.nonce, + signatureState.action, + signatureState.actor, + signatureState.secret, + signatureState.validAfter, + ], + }; +} diff --git a/packages/stackflow-agent/src/watcher.js b/packages/stackflow-agent/src/watcher.js new file mode 100644 index 0000000..9338c69 --- /dev/null +++ b/packages/stackflow-agent/src/watcher.js @@ -0,0 +1,321 @@ +import { normalizeClosureEvent, parseUnsignedBigInt } from "./utils.js"; + +const DEFAULT_INTERVAL_MS = 60 * 60 * 1000; + +function assertFunction(value, fieldName) { + if (typeof value !== "function") { + throw new Error(`${fieldName} must be a function`); + } + return value; +} + +function asRecord(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value; +} + +function readField(record, names) { + if (!record) { + return null; + } + for (const name of names) { + if (Object.prototype.hasOwnProperty.call(record, name)) { + return record[name]; + } + } + return null; +} + +function normalizeOptionalPrincipal(value) { + if (value == null) { + return null; + } + if (typeof value === "string") { + const principal = value.trim(); + return principal || null; + } + const record = asRecord(value); + if (!record) { + return null; + } + if ( + typeof record.type === "string" && + (record.type.toLowerCase() === "none" || + record.type.toLowerCase() === "optionalnone" || + record.type.toLowerCase() === "optional_none") + ) { + return null; + } + if ( + typeof record.type === "string" && + (record.type.toLowerCase() === "some" || + record.type.toLowerCase() === "optionalsome" || + record.type.toLowerCase() === "optional_some") + ) { + return normalizeOptionalPrincipal(record.value); + } + if (typeof record.value === "string") { + return normalizeOptionalPrincipal(record.value); + } + if (typeof record.principal === "string") { + return normalizeOptionalPrincipal(record.principal); + } + return null; +} + +function toClosureFromPipeState({ trackedPipe, pipeState }) { + const pipe = asRecord(pipeState); + if (!pipe) { + return null; + } + const closer = normalizeOptionalPrincipal( + readField(pipe, ["closer", "closerPrincipal", "closingPrincipal"]), + ); + if (!closer) { + return null; + } + + const nonceRaw = readField(pipe, ["nonce"]); + const expiresAtRaw = readField(pipe, ["expires-at", "expiresAt"]); + const balance1Raw = readField(pipe, ["balance-1", "balance1"]); + const balance2Raw = readField(pipe, ["balance-2", "balance2"]); + if (nonceRaw == null || expiresAtRaw == null || balance1Raw == null || balance2Raw == null) { + return null; + } + + const principal1 = trackedPipe.pipeKey?.["principal-1"]; + const closureMyBalance = + principal1 && principal1 === trackedPipe.localPrincipal ? balance1Raw : balance2Raw; + const eventNameRaw = readField(pipe, ["event", "eventName"]); + const eventName = + eventNameRaw === "force-cancel" || eventNameRaw === "force-close" + ? eventNameRaw + : "force-close"; + const blockHeightRaw = readField(pipe, ["block-height", "blockHeight"]); + const txidRaw = readField(pipe, ["txid", "txId"]); + const syntheticTxid = `readonly:${trackedPipe.pipeId}:${String(nonceRaw)}:${closer}`; + + return { + contractId: trackedPipe.contractId, + pipeKey: trackedPipe.pipeKey, + eventName, + nonce: String(nonceRaw), + closer, + txid: txidRaw == null ? syntheticTxid : String(txidRaw), + blockHeight: blockHeightRaw == null ? "0" : String(blockHeightRaw), + expiresAt: String(expiresAtRaw), + closureMyBalance: String(closureMyBalance), + }; +} + +function assertPositiveInt(value, fieldName) { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive integer`); + } + return parsed; +} + +export class HourlyClosureWatcher { + constructor({ + agentService, + listClosureEvents = null, + getPipeState = null, + onError = null, + intervalMs = DEFAULT_INTERVAL_MS, + walletPassword = null, + }) { + if (!agentService) { + throw new Error("agentService is required"); + } + + this.agentService = agentService; + this.listClosureEvents = + typeof listClosureEvents === "function" ? listClosureEvents : null; + this.getPipeState = typeof getPipeState === "function" ? getPipeState : null; + if (!this.listClosureEvents && !this.getPipeState) { + throw new Error("listClosureEvents or getPipeState must be provided"); + } + this.intervalMs = assertPositiveInt(intervalMs, "intervalMs"); + this.onError = typeof onError === "function" ? onError : null; + this.walletPassword = walletPassword; + this.timer = null; + this.running = false; + } + + start() { + if (this.timer) { + return; + } + + this.timer = setInterval(() => { + void this.runOnce().catch((error) => { + if (this.onError) { + this.onError(error); + } else { + console.error( + `[stackflow-agent] watcher error: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); + }, this.intervalMs); + this.timer.unref?.(); + } + + stop() { + if (!this.timer) { + return; + } + clearInterval(this.timer); + this.timer = null; + } + + async runOnce() { + if (this.getPipeState) { + return this.runOnceByReadonlyPipe(); + } + return this.runOnceByEvents(); + } + + async runOnceByReadonlyPipe() { + if (this.running) { + return { + ok: true, + skipped: true, + reason: "already-running", + }; + } + + this.running = true; + try { + const trackedPipes = this.agentService.getTrackedPipes(); + if (!Array.isArray(trackedPipes) || trackedPipes.length === 0) { + return { + ok: true, + mode: "readonly-pipe", + pipesScanned: 0, + closuresFound: 0, + disputesSubmitted: 0, + }; + } + + let closuresFound = 0; + let disputesSubmitted = 0; + let pipesScanned = 0; + for (const trackedPipe of trackedPipes) { + pipesScanned += 1; + const pipeState = await this.getPipeState({ + contractId: trackedPipe.contractId, + token: trackedPipe.token ?? null, + pipeKey: trackedPipe.pipeKey, + forPrincipal: trackedPipe.localPrincipal, + withPrincipal: trackedPipe.counterpartyPrincipal, + pipeId: trackedPipe.pipeId, + }); + const rawClosure = toClosureFromPipeState({ + trackedPipe, + pipeState, + }); + if (!rawClosure) { + continue; + } + let closure; + try { + closure = normalizeClosureEvent(rawClosure); + } catch { + continue; + } + + closuresFound += 1; + this.agentService.stateStore.recordClosure(closure); + const disputeResult = await this.agentService.disputeClosure({ + closureEvent: closure, + walletPassword: this.walletPassword, + }); + if (disputeResult.submitted) { + disputesSubmitted += 1; + } + } + + return { + ok: true, + mode: "readonly-pipe", + pipesScanned, + closuresFound, + disputesSubmitted, + }; + } finally { + this.running = false; + } + } + + async runOnceByEvents() { + if (this.running) { + return { + ok: true, + skipped: true, + reason: "already-running", + }; + } + + this.running = true; + try { + const fromBlockHeight = this.agentService.stateStore.getWatcherCursor(); + const events = await this.listClosureEvents({ + fromBlockHeight, + }); + if (!Array.isArray(events) || events.length === 0) { + return { + ok: true, + scanned: 0, + disputesSubmitted: 0, + fromBlockHeight, + toBlockHeight: fromBlockHeight, + }; + } + + let highestBlock = parseUnsignedBigInt(fromBlockHeight, "fromBlockHeight"); + let disputesSubmitted = 0; + let scanned = 0; + + for (const rawEvent of events) { + let closure; + try { + closure = normalizeClosureEvent(rawEvent); + } catch { + continue; + } + scanned += 1; + this.agentService.stateStore.recordClosure(closure); + + const disputeResult = await this.agentService.disputeClosure({ + closureEvent: closure, + walletPassword: this.walletPassword, + }); + if (disputeResult.submitted) { + disputesSubmitted += 1; + } + + const block = parseUnsignedBigInt(closure.blockHeight, "blockHeight"); + if (block > highestBlock) { + highestBlock = block; + } + } + + this.agentService.stateStore.setWatcherCursor(highestBlock.toString(10)); + return { + ok: true, + scanned, + disputesSubmitted, + fromBlockHeight, + toBlockHeight: highestBlock.toString(10), + }; + } finally { + this.running = false; + } + } +} diff --git a/packages/x402-client/README.md b/packages/x402-client/README.md new file mode 100644 index 0000000..01b9ed9 --- /dev/null +++ b/packages/x402-client/README.md @@ -0,0 +1,117 @@ +# @stackflow/x402-client (scaffold) + +Minimal SDK scaffold for API callers interacting with the Stackflow x402 +gateway. + +This package currently provides: + +1. `X402Client`: fetch wrapper with challenge/retry flow for `402` +2. `SqliteX402StateStore`: local SQLite store for pipe state, proof replay, and + per-pipe locks +3. `StackflowNodePipeStateSource`: fetches authoritative pipe state from + stackflow-node (`GET /pipes`) and can sync it into SQLite + +## Runtime + +Uses Node built-in `node:sqlite`, so run on a Node version that includes it. + +## Quick Start + +```js +import { + StackflowNodePipeStateSource, + X402Client, + SqliteX402StateStore, + buildPipeStateKey, +} from "./src/index.js"; + +const store = new SqliteX402StateStore({ + dbFile: "./tmp/x402-client.db", +}); + +const proofProvider = { + async createProof(ctx) { + // Optional: fetch canonical pipe status from stackflow-node on demand. + // const status = await ctx.pipeStateSource.getPipeStatus({ + // principal: "ST_CLIENT...", + // counterpartyPrincipal: "ST_SERVER...", + // contractId: "ST...stackflow", + // }); + + // Replace this with your real proof flow: + // 1) read/update per-pipe nonce under store.withPipeLock(...) + // 2) build Stackflow structured payload + // 3) sign payload + // 4) return direct or indirect proof object + return { + mode: "direct", + contractId: "ST...stackflow", + forPrincipal: "ST_SERVER...", + withPrincipal: "ST_CLIENT...", + token: null, + amount: "10", + myBalance: "1010", + theirBalance: "90", + theirSignature: "0x...", + nonce: "1", + action: "1", + actor: "ST_CLIENT...", + hashedSecret: null, + validAfter: null, + beneficialOnly: false, + }; + }, +}; + +const pipeStateSource = new StackflowNodePipeStateSource({ + stackflowNodeBaseUrl: "http://127.0.0.1:8787", +}); + +const client = new X402Client({ + gatewayBaseUrl: "http://127.0.0.1:8790", + proofProvider, + stateStore: store, + pipeStateSource, + proactivePayment: true, +}); + +const response = await client.request("/paid-content", { + method: "GET", +}); +console.log(response.status); +``` + +## Pipe Lock Example + +Use per-pipe lock to avoid nonce races across concurrent requests: + +```js +const pipeKey = buildPipeStateKey({ + contractId: "ST...stackflow", + forPrincipal: "ST_SERVER...", + withPrincipal: "ST_CLIENT...", + token: null, +}); + +await store.withPipeLock(pipeKey, async () => { + const existing = store.getPipeState(pipeKey); + const nextNonce = existing ? (BigInt(existing.nonce) + 1n).toString(10) : "1"; + store.setPipeState({ + pipeKey, + contractId: "ST...stackflow", + forPrincipal: "ST_SERVER...", + withPrincipal: "ST_CLIENT...", + token: null, + nonce: nextNonce, + myBalance: "100", + theirBalance: "0", + }); +}); +``` + +## Notes + +1. SQLite store is local client coordination/cache, not source of truth. +2. For latest channel state, use `StackflowNodePipeStateSource`. +3. This scaffold does not include an opinionated direct-proof signer yet. +4. For retries, request bodies must be replayable (not one-shot streams). diff --git a/packages/x402-client/package.json b/packages/x402-client/package.json new file mode 100644 index 0000000..cd23318 --- /dev/null +++ b/packages/x402-client/package.json @@ -0,0 +1,10 @@ +{ + "name": "@stackflow/x402-client", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js" + } +} diff --git a/packages/x402-client/src/client.js b/packages/x402-client/src/client.js new file mode 100644 index 0000000..7b8e368 --- /dev/null +++ b/packages/x402-client/src/client.js @@ -0,0 +1,245 @@ +import { computeProofHash } from "./sqlite-state-store.js"; + +const DEFAULT_PAYMENT_HEADER = "x-x402-payment"; + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeBaseUrl(value) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error("gatewayBaseUrl is required"); + } + const parsed = new URL(text); + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + const normalized = parsed.toString(); + return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; +} + +function normalizeInt(value, fallback, fieldName) { + if (value === undefined || value === null || value === "") { + return fallback; + } + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive integer`); + } + return parsed; +} + +function resolveRequestUrl(input, gatewayBaseUrl) { + if (input instanceof URL) { + return input; + } + return new URL(String(input), gatewayBaseUrl); +} + +function encodeProofHeader(proof) { + return Buffer.from(JSON.stringify(proof)).toString("base64url"); +} + +function cloneHeaders(headersLike) { + return new Headers(headersLike || {}); +} + +function ensureRetryableBody(init) { + const body = init?.body; + if (!body) { + return; + } + if (typeof body === "string" || body instanceof Uint8Array || Buffer.isBuffer(body)) { + return; + } + if (body instanceof URLSearchParams || body instanceof FormData || body instanceof Blob) { + return; + } + if (typeof body === "object" && typeof body.getReader === "function") { + throw new Error( + "request body is a stream and cannot be retried automatically; pass a replayable body", + ); + } +} + +export async function parseX402Challenge(response) { + if (!(response instanceof Response) || response.status !== 402) { + return null; + } + + let body = null; + try { + body = await response.json(); + } catch { + return null; + } + if (!isRecord(body) || !isRecord(body.payment)) { + return null; + } + if (body.error !== "payment required") { + return null; + } + return body; +} + +export class X402Client { + constructor({ + gatewayBaseUrl, + proofProvider, + stateStore = null, + pipeStateSource = null, + fetchFn = globalThis.fetch?.bind(globalThis), + proactivePayment = false, + maxPaymentAttempts = 2, + paymentHeaderName = DEFAULT_PAYMENT_HEADER, + localReplayTtlMs = 60_000, + onEvent = null, + }) { + this.gatewayBaseUrl = normalizeBaseUrl(gatewayBaseUrl); + this.proofProvider = proofProvider ?? null; + this.stateStore = stateStore; + this.pipeStateSource = pipeStateSource; + this.fetchFn = fetchFn; + this.proactivePayment = Boolean(proactivePayment); + this.maxPaymentAttempts = normalizeInt( + maxPaymentAttempts, + 2, + "maxPaymentAttempts", + ); + this.paymentHeaderName = String(paymentHeaderName || DEFAULT_PAYMENT_HEADER).trim(); + if (!this.paymentHeaderName) { + throw new Error("paymentHeaderName must not be empty"); + } + this.localReplayTtlMs = normalizeInt(localReplayTtlMs, 60_000, "localReplayTtlMs"); + this.onEvent = typeof onEvent === "function" ? onEvent : null; + + if (typeof this.fetchFn !== "function") { + throw new Error("fetchFn is required"); + } + } + + emitEvent(event) { + if (!this.onEvent) { + return; + } + try { + this.onEvent(event); + } catch { + // Event hooks must never break request flow. + } + } + + async request(input, init = {}) { + ensureRetryableBody(init); + const url = resolveRequestUrl(input, this.gatewayBaseUrl); + const method = String(init.method || "GET").toUpperCase(); + const pathQuery = `${url.pathname}${url.search}`; + + let challenge = null; + let paymentAttempts = 0; + let attemptedWithoutPayment = false; + + while (true) { + const shouldAttachProof = + challenge !== null || + (this.proactivePayment && paymentAttempts < this.maxPaymentAttempts); + + let proof = null; + let proofHash = null; + const headers = cloneHeaders(init.headers); + + if (shouldAttachProof) { + if (!this.proofProvider || typeof this.proofProvider.createProof !== "function") { + throw new Error("proofProvider.createProof is required for paid requests"); + } + + proof = await this.proofProvider.createProof({ + method, + url, + path: url.pathname, + query: url.search, + challenge, + paymentAttempt: paymentAttempts + 1, + paymentHeaderName: this.paymentHeaderName, + stateStore: this.stateStore, + pipeStateSource: this.pipeStateSource, + }); + paymentAttempts += 1; + + proofHash = computeProofHash({ method, pathQuery, proof }); + if (this.stateStore?.isProofConsumed?.(proofHash)) { + this.emitEvent({ + type: "proof-skip-local-replay", + proofHash, + method, + pathQuery, + }); + continue; + } + + headers.set(this.paymentHeaderName, encodeProofHeader(proof)); + } else { + attemptedWithoutPayment = true; + } + + const response = await this.fetchFn(url.toString(), { + ...init, + headers, + }); + + if (response.status !== 402) { + if ( + proofHash && + this.stateStore?.markConsumedProof && + response.status >= 200 && + response.status < 300 + ) { + this.stateStore.markConsumedProof( + proofHash, + Date.now() + this.localReplayTtlMs, + ); + } + this.emitEvent({ + type: "request-complete", + status: response.status, + method, + pathQuery, + paid: Boolean(proof), + }); + return response; + } + + const parsedChallenge = await parseX402Challenge(response.clone()); + if (!parsedChallenge) { + this.emitEvent({ + type: "challenge-unparseable", + status: response.status, + method, + pathQuery, + }); + return response; + } + challenge = parsedChallenge; + this.emitEvent({ + type: "challenge-received", + reason: challenge.reason, + method, + pathQuery, + }); + + const hasProvider = Boolean( + this.proofProvider && typeof this.proofProvider.createProof === "function", + ); + if (!hasProvider) { + return response; + } + if (paymentAttempts >= this.maxPaymentAttempts) { + return response; + } + if (!attemptedWithoutPayment && !this.proactivePayment) { + attemptedWithoutPayment = true; + } + } + } +} diff --git a/packages/x402-client/src/index.js b/packages/x402-client/src/index.js new file mode 100644 index 0000000..1d6c5e4 --- /dev/null +++ b/packages/x402-client/src/index.js @@ -0,0 +1,11 @@ +export { + buildPipeStateKey, + computeProofHash, + SqliteX402StateStore, +} from "./sqlite-state-store.js"; +export { + selectBestPipeFromNode, + toPipeStatusFromObservedPipe, + StackflowNodePipeStateSource, +} from "./pipe-state-source.js"; +export { parseX402Challenge, X402Client } from "./client.js"; diff --git a/packages/x402-client/src/pipe-state-source.js b/packages/x402-client/src/pipe-state-source.js new file mode 100644 index 0000000..4c949bc --- /dev/null +++ b/packages/x402-client/src/pipe-state-source.js @@ -0,0 +1,271 @@ +import { buildPipeStateKey } from "./sqlite-state-store.js"; + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function assertPrincipal(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be a non-empty principal`); + } + return text; +} + +function assertContractId(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text || !text.includes(".")) { + throw new Error(`${fieldName} must be a contract id`); + } + return text; +} + +function normalizeBaseUrl(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} is required`); + } + const parsed = new URL(text); + parsed.pathname = ""; + parsed.search = ""; + parsed.hash = ""; + const normalized = parsed.toString(); + return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized; +} + +function parsePositiveInt(value, fallback, fieldName) { + if (value === undefined || value === null || value === "") { + return fallback; + } + const parsed = Number.parseInt(String(value), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive integer`); + } + return parsed; +} + +function parseUnsignedBigInt(value) { + if (typeof value !== "string" || !/^\d+$/.test(value)) { + return null; + } + try { + return BigInt(value); + } catch { + return null; + } +} + +export function selectBestPipeFromNode({ + pipes, + principal, + counterpartyPrincipal, + contractId, +}) { + if (!Array.isArray(pipes)) { + return null; + } + + let best = null; + let bestNonce = -1n; + let bestUpdatedAt = ""; + + for (const candidate of pipes) { + if (!isRecord(candidate) || candidate.contractId !== contractId) { + continue; + } + const pipeKey = isRecord(candidate.pipeKey) ? candidate.pipeKey : null; + if (!pipeKey) { + continue; + } + const principal1 = + typeof pipeKey["principal-1"] === "string" ? pipeKey["principal-1"] : null; + const principal2 = + typeof pipeKey["principal-2"] === "string" ? pipeKey["principal-2"] : null; + if (!principal1 || !principal2) { + continue; + } + const samePair = + (principal1 === principal && principal2 === counterpartyPrincipal) || + (principal1 === counterpartyPrincipal && principal2 === principal); + if (!samePair) { + continue; + } + + const nonce = + parseUnsignedBigInt( + typeof candidate.nonce === "string" ? candidate.nonce : "0", + ) ?? 0n; + const updatedAt = typeof candidate.updatedAt === "string" ? candidate.updatedAt : ""; + if (!best || nonce > bestNonce || (nonce === bestNonce && updatedAt > bestUpdatedAt)) { + best = candidate; + bestNonce = nonce; + bestUpdatedAt = updatedAt; + } + } + + return best; +} + +export function toPipeStatusFromObservedPipe({ + pipe, + principal, +}) { + if (!isRecord(pipe)) { + return { + hasPipe: false, + canPay: false, + myConfirmed: "0", + myPending: "0", + theirConfirmed: "0", + theirPending: "0", + nonce: "0", + source: null, + event: null, + updatedAt: null, + contractId: null, + token: null, + forPrincipal: null, + withPrincipal: null, + pipeKey: null, + }; + } + + const pipeKey = isRecord(pipe.pipeKey) ? pipe.pipeKey : null; + const principal1 = pipeKey && typeof pipeKey["principal-1"] === "string" + ? pipeKey["principal-1"] + : null; + const principal2 = pipeKey && typeof pipeKey["principal-2"] === "string" + ? pipeKey["principal-2"] + : null; + const token = pipeKey && typeof pipeKey.token === "string" ? pipeKey.token : null; + const useBalance1 = principal1 === principal; + + const balance1 = parseUnsignedBigInt(String(pipe.balance1 ?? "0")) ?? 0n; + const balance2 = parseUnsignedBigInt(String(pipe.balance2 ?? "0")) ?? 0n; + const pending1 = parseUnsignedBigInt(String(pipe.pending1Amount ?? "0")) ?? 0n; + const pending2 = parseUnsignedBigInt(String(pipe.pending2Amount ?? "0")) ?? 0n; + const myConfirmed = useBalance1 ? balance1 : balance2; + const myPending = useBalance1 ? pending1 : pending2; + const theirConfirmed = useBalance1 ? balance2 : balance1; + const theirPending = useBalance1 ? pending2 : pending1; + const forPrincipal = principal; + const withPrincipal = principal1 === principal ? principal2 : principal1; + + return { + hasPipe: Boolean(principal1 && principal2), + canPay: myConfirmed > 0n, + myConfirmed: myConfirmed.toString(10), + myPending: myPending.toString(10), + theirConfirmed: theirConfirmed.toString(10), + theirPending: theirPending.toString(10), + nonce: (parseUnsignedBigInt(String(pipe.nonce ?? "0")) ?? 0n).toString(10), + source: typeof pipe.source === "string" ? pipe.source : null, + event: typeof pipe.event === "string" ? pipe.event : null, + updatedAt: typeof pipe.updatedAt === "string" ? pipe.updatedAt : null, + contractId: typeof pipe.contractId === "string" ? pipe.contractId : null, + token, + forPrincipal: forPrincipal || null, + withPrincipal: withPrincipal || null, + pipeKey: pipeKey || null, + }; +} + +export class StackflowNodePipeStateSource { + constructor({ + stackflowNodeBaseUrl, + fetchFn = globalThis.fetch?.bind(globalThis), + timeoutMs = 10_000, + pipesLimit = 200, + }) { + this.stackflowNodeBaseUrl = normalizeBaseUrl( + stackflowNodeBaseUrl, + "stackflowNodeBaseUrl", + ); + this.fetchFn = fetchFn; + this.timeoutMs = parsePositiveInt(timeoutMs, 10_000, "timeoutMs"); + this.pipesLimit = parsePositiveInt(pipesLimit, 200, "pipesLimit"); + if (typeof this.fetchFn !== "function") { + throw new Error("fetchFn is required"); + } + } + + async getPipeStatus({ + principal, + counterpartyPrincipal, + contractId, + }) { + const normalizedPrincipal = assertPrincipal(principal, "principal"); + const normalizedCounterparty = assertPrincipal( + counterpartyPrincipal, + "counterpartyPrincipal", + ); + const normalizedContract = assertContractId(contractId, "contractId"); + const query = new URLSearchParams({ + principal: normalizedPrincipal, + limit: String(this.pipesLimit), + }); + + const response = await this.fetchFn( + `${this.stackflowNodeBaseUrl}/pipes?${query.toString()}`, + { + method: "GET", + signal: AbortSignal.timeout(this.timeoutMs), + }, + ); + + let body = null; + try { + body = await response.json(); + } catch { + throw new Error(`stackflow-node /pipes returned non-JSON (status=${response.status})`); + } + + if (!response.ok || !isRecord(body)) { + throw new Error(`stackflow-node /pipes request failed (status=${response.status})`); + } + const selectedPipe = selectBestPipeFromNode({ + pipes: body.pipes, + principal: normalizedPrincipal, + counterpartyPrincipal: normalizedCounterparty, + contractId: normalizedContract, + }); + return toPipeStatusFromObservedPipe({ + pipe: selectedPipe, + principal: normalizedPrincipal, + }); + } + + async syncPipeState({ + principal, + counterpartyPrincipal, + contractId, + stateStore, + }) { + const status = await this.getPipeStatus({ + principal, + counterpartyPrincipal, + contractId, + }); + + if (!status.hasPipe || !stateStore || typeof stateStore.setPipeState !== "function") { + return status; + } + const pipeKey = buildPipeStateKey({ + contractId: status.contractId, + forPrincipal: status.forPrincipal, + withPrincipal: status.withPrincipal, + token: status.token, + }); + stateStore.setPipeState({ + pipeKey, + contractId: status.contractId, + forPrincipal: status.forPrincipal, + withPrincipal: status.withPrincipal, + token: status.token, + nonce: status.nonce, + myBalance: status.myConfirmed, + theirBalance: status.theirConfirmed, + }); + return status; + } +} diff --git a/packages/x402-client/src/sqlite-state-store.js b/packages/x402-client/src/sqlite-state-store.js new file mode 100644 index 0000000..2d407a1 --- /dev/null +++ b/packages/x402-client/src/sqlite-state-store.js @@ -0,0 +1,326 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function assertNonEmptyString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!text) { + throw new Error(`${fieldName} must be a non-empty string`); + } + return text; +} + +function assertUintString(value, fieldName) { + const text = String(value ?? "").trim(); + if (!/^\d+$/.test(text)) { + throw new Error(`${fieldName} must be a uint string`); + } + return text; +} + +function assertPositiveInteger(value, fieldName) { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${fieldName} must be a positive integer`); + } + return parsed; +} + +export function buildPipeStateKey({ + contractId, + forPrincipal, + withPrincipal, + token = null, +}) { + const contract = assertNonEmptyString(contractId, "contractId"); + const forP = assertNonEmptyString(forPrincipal, "forPrincipal"); + const withP = assertNonEmptyString(withPrincipal, "withPrincipal"); + const tokenPart = token ? String(token).trim() : "stx"; + return `${contract}|${tokenPart}|${forP}|${withP}`; +} + +export function computeProofHash({ method, pathQuery, proof }) { + const canonicalMethod = String(method || "GET").toUpperCase(); + const canonicalPathQuery = String(pathQuery || "/"); + return createHash("sha256") + .update(canonicalMethod) + .update("\n") + .update(canonicalPathQuery) + .update("\n") + .update(JSON.stringify(proof)) + .digest("hex"); +} + +export class SqliteX402StateStore { + constructor({ + dbFile, + lockTtlMs = 15_000, + lockWaitTimeoutMs = 5_000, + lockPollIntervalMs = 50, + busyTimeoutMs = 5_000, + }) { + this.dbFile = assertNonEmptyString(dbFile, "dbFile"); + this.lockTtlMs = assertPositiveInteger(lockTtlMs, "lockTtlMs"); + this.lockWaitTimeoutMs = assertPositiveInteger(lockWaitTimeoutMs, "lockWaitTimeoutMs"); + this.lockPollIntervalMs = assertPositiveInteger(lockPollIntervalMs, "lockPollIntervalMs"); + this.busyTimeoutMs = assertPositiveInteger(busyTimeoutMs, "busyTimeoutMs"); + + const directory = path.dirname(this.dbFile); + fs.mkdirSync(directory, { recursive: true }); + + this.db = new DatabaseSync(this.dbFile); + this.db.exec("PRAGMA journal_mode = WAL;"); + this.db.exec("PRAGMA synchronous = NORMAL;"); + this.db.exec(`PRAGMA busy_timeout = ${this.busyTimeoutMs};`); + + this.db.exec(` + CREATE TABLE IF NOT EXISTS pipe_states ( + pipe_key TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + for_principal TEXT NOT NULL, + with_principal TEXT NOT NULL, + token TEXT, + nonce TEXT NOT NULL, + my_balance TEXT NOT NULL, + their_balance TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS consumed_proofs ( + proof_hash TEXT PRIMARY KEY, + expires_at_ms INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_consumed_proofs_expires + ON consumed_proofs(expires_at_ms); + + CREATE TABLE IF NOT EXISTS pipe_locks ( + pipe_key TEXT PRIMARY KEY, + lock_token TEXT NOT NULL, + expires_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_pipe_locks_expires + ON pipe_locks(expires_at_ms); + `); + + this.selectPipeStateStmt = this.db.prepare(` + SELECT * + FROM pipe_states + WHERE pipe_key = ? + `); + this.upsertPipeStateStmt = this.db.prepare(` + INSERT INTO pipe_states ( + pipe_key, + contract_id, + for_principal, + with_principal, + token, + nonce, + my_balance, + their_balance, + updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(pipe_key) DO UPDATE SET + contract_id = excluded.contract_id, + for_principal = excluded.for_principal, + with_principal = excluded.with_principal, + token = excluded.token, + nonce = excluded.nonce, + my_balance = excluded.my_balance, + their_balance = excluded.their_balance, + updated_at_ms = excluded.updated_at_ms + `); + + this.upsertConsumedProofStmt = this.db.prepare(` + INSERT INTO consumed_proofs ( + proof_hash, + expires_at_ms, + created_at_ms + ) VALUES (?, ?, ?) + ON CONFLICT(proof_hash) DO UPDATE SET + expires_at_ms = excluded.expires_at_ms + `); + this.selectConsumedProofStmt = this.db.prepare(` + SELECT proof_hash + FROM consumed_proofs + WHERE proof_hash = ? + AND expires_at_ms > ? + `); + this.deleteExpiredConsumedStmt = this.db.prepare(` + DELETE FROM consumed_proofs + WHERE expires_at_ms <= ? + `); + + this.tryAcquirePipeLockStmt = this.db.prepare(` + INSERT INTO pipe_locks ( + pipe_key, + lock_token, + expires_at_ms, + updated_at_ms + ) VALUES (?, ?, ?, ?) + ON CONFLICT(pipe_key) DO UPDATE SET + lock_token = excluded.lock_token, + expires_at_ms = excluded.expires_at_ms, + updated_at_ms = excluded.updated_at_ms + WHERE pipe_locks.expires_at_ms <= ? + `); + this.releasePipeLockStmt = this.db.prepare(` + DELETE FROM pipe_locks + WHERE pipe_key = ? + AND lock_token = ? + `); + this.deleteExpiredLocksStmt = this.db.prepare(` + DELETE FROM pipe_locks + WHERE expires_at_ms <= ? + `); + } + + close() { + if (!this.db) { + return; + } + this.db.close(); + this.db = null; + } + + assertOpen() { + if (!this.db) { + throw new Error("SQLite store is closed"); + } + } + + getPipeState(pipeKey) { + this.assertOpen(); + const key = assertNonEmptyString(pipeKey, "pipeKey"); + const row = this.selectPipeStateStmt.get(key); + if (!row) { + return null; + } + return { + pipeKey: row.pipe_key, + contractId: row.contract_id, + forPrincipal: row.for_principal, + withPrincipal: row.with_principal, + token: row.token, + nonce: row.nonce, + myBalance: row.my_balance, + theirBalance: row.their_balance, + updatedAtMs: Number.parseInt(String(row.updated_at_ms), 10), + }; + } + + setPipeState(state) { + this.assertOpen(); + const pipeKey = assertNonEmptyString(state.pipeKey, "state.pipeKey"); + const contractId = assertNonEmptyString(state.contractId, "state.contractId"); + const forPrincipal = assertNonEmptyString(state.forPrincipal, "state.forPrincipal"); + const withPrincipal = assertNonEmptyString(state.withPrincipal, "state.withPrincipal"); + const token = state.token == null ? null : String(state.token).trim(); + const nonce = assertUintString(state.nonce, "state.nonce"); + const myBalance = assertUintString(state.myBalance, "state.myBalance"); + const theirBalance = assertUintString(state.theirBalance, "state.theirBalance"); + const updatedAtMs = Date.now(); + + this.upsertPipeStateStmt.run( + pipeKey, + contractId, + forPrincipal, + withPrincipal, + token, + nonce, + myBalance, + theirBalance, + updatedAtMs, + ); + } + + markConsumedProof(proofHash, expiresAtMs) { + this.assertOpen(); + const hash = assertNonEmptyString(proofHash, "proofHash").toLowerCase(); + const expires = assertPositiveInteger(expiresAtMs, "expiresAtMs"); + const createdAtMs = Date.now(); + this.upsertConsumedProofStmt.run(hash, expires, createdAtMs); + } + + isProofConsumed(proofHash, nowMs = Date.now()) { + this.assertOpen(); + const hash = assertNonEmptyString(proofHash, "proofHash").toLowerCase(); + const now = assertPositiveInteger(nowMs, "nowMs"); + return Boolean(this.selectConsumedProofStmt.get(hash, now)); + } + + purgeExpired(nowMs = Date.now()) { + this.assertOpen(); + const now = assertPositiveInteger(nowMs, "nowMs"); + const consumed = this.deleteExpiredConsumedStmt.run(now); + const locks = this.deleteExpiredLocksStmt.run(now); + return { + consumedDeleted: consumed.changes, + locksDeleted: locks.changes, + }; + } + + async acquirePipeLock(pipeKey, options = {}) { + this.assertOpen(); + const key = assertNonEmptyString(pipeKey, "pipeKey"); + const timeoutMs = options.timeoutMs + ? assertPositiveInteger(options.timeoutMs, "options.timeoutMs") + : this.lockWaitTimeoutMs; + const ttlMs = options.ttlMs + ? assertPositiveInteger(options.ttlMs, "options.ttlMs") + : this.lockTtlMs; + const pollMs = options.pollIntervalMs + ? assertPositiveInteger(options.pollIntervalMs, "options.pollIntervalMs") + : this.lockPollIntervalMs; + const deadline = Date.now() + timeoutMs; + const token = randomUUID(); + + while (Date.now() <= deadline) { + const now = Date.now(); + const expiresAt = now + ttlMs; + const result = this.tryAcquirePipeLockStmt.run( + key, + token, + expiresAt, + now, + now, + ); + if (result.changes > 0) { + return token; + } + await sleep(pollMs); + } + + throw new Error(`timed out acquiring lock for ${key}`); + } + + releasePipeLock(pipeKey, lockToken) { + this.assertOpen(); + const key = assertNonEmptyString(pipeKey, "pipeKey"); + const token = assertNonEmptyString(lockToken, "lockToken"); + const result = this.releasePipeLockStmt.run(key, token); + return result.changes > 0; + } + + async withPipeLock(pipeKey, fn, options = {}) { + if (typeof fn !== "function") { + throw new Error("fn must be a function"); + } + const token = await this.acquirePipeLock(pipeKey, options); + try { + return await fn(); + } finally { + this.releasePipeLock(pipeKey, token); + } + } +} diff --git a/server/STACKFLOW_AGENT_DESIGN.md b/server/STACKFLOW_AGENT_DESIGN.md new file mode 100644 index 0000000..b2079bf --- /dev/null +++ b/server/STACKFLOW_AGENT_DESIGN.md @@ -0,0 +1,98 @@ +# Stackflow Agent Design + +## Goal + +Provide a minimal agent-friendly Stackflow runtime that does not require local +`stacks-node` or `stackflow-node`. + +Current constraints: + +1. local SQLite state only +2. AIBTC wallet-based transaction signing +3. periodic chain watcher every hour +4. auto-dispute closures when local signatures are newer and beneficial + +## Required Capabilities + +Agent runtime should implement: + +1. open a new pipe +2. generate/validate transfer messages +3. sign transfer messages with policy checks +4. persist per-pipe state: + - nonce + - balances + - latest signatures +5. poll chain and dispute `force-cancel`/`force-close` when eligible + +Concrete service operations in scaffold: + +1. `trackPipe` +2. `recordSignedState` +3. `buildOutgoingTransfer` +4. `validateIncomingTransfer` +5. `acceptIncomingTransfer` +6. `disputeClosure` + +## Missing-but-Important Items + +Include: + +1. per-pipe mutex/lock when generating outgoing nonces +2. idempotency for inbound/outbound transfer requests +3. signer fee-balance checks and alerting +4. watcher cursor persistence and crash-safe resume +5. audit log of sign/reject/dispute decisions + +## Architecture + +1. `AgentStateStore` (SQLite) +2. `StackflowAgentService` (business logic) +3. `AibtcWalletAdapter` (MCP tool bridge) +4. `AibtcPipeStateSource` (read-only `get-pipe` polling) +5. `HourlyClosureWatcher` (default interval: 1 hour) + +## Data Model (SQLite) + +1. `tracked_pipes` +2. `signature_states` +3. `closures` +4. `watcher_cursor` + +## Watcher Policy + +1. schedule every hour (`60 * 60 * 1000`) +2. list tracked pipes from local SQLite +3. for each tracked pipe, call Stackflow read-only `get-pipe` +4. if `closer` is set, treat pipe as in forced closure and evaluate dispute +5. for each candidate closure: + - compare closure nonce vs stored signed nonce + - if stored nonce is newer and beneficial, call `dispute-closure-for` +6. store dispute txid + +Note: + +1. Stackflow dispute window is 144 Bitcoin blocks. +2. Hourly polling is acceptable for now but should still emit stale-watcher + alerts if a run fails repeatedly. + +## AIBTC Wallet Integration + +Use AIBTC MCP wallet tools through an injected `invokeTool(name, args)`: + +1. `sip018_sign` for off-chain transfer signatures +2. `call_contract` for on-chain actions (`fund-pipe`, `dispute-closure-for`) +3. a read-only call tool for `get-pipe` (tool name is MCP-runtime specific) + +## Setup Checklist + +1. configure contract id and network +2. initialize SQLite file path +3. wire MCP tool invoker for AIBTC wallet +4. track each opened pipe in `tracked_pipes` +5. persist every successful signature state update +6. start hourly watcher +7. monitor alerts: + - watcher failures + - dispute call failures + - signer low-fee balance diff --git a/server/X402_CLIENT_SDK_DESIGN.md b/server/X402_CLIENT_SDK_DESIGN.md new file mode 100644 index 0000000..8028788 --- /dev/null +++ b/server/X402_CLIENT_SDK_DESIGN.md @@ -0,0 +1,224 @@ +# Stackflow x402 Client SDK Design + +## Purpose + +Define a practical client-side SDK for API callers consuming endpoints behind +the Stackflow x402 gateway. + +This document focuses on: + +1. request/response behavior for API clients (not browser UI) +2. TypeScript interfaces for an SDK package +3. operational requirements for nonce safety and retries +4. recommended server capabilities that improve client UX + +## Scope + +Current gateway behavior is defined by `server/src/x402-gateway.ts`: + +1. protected routes require header `x-x402-payment` +2. missing/invalid payment returns HTTP `402` with machine-readable challenge +3. direct mode is verified via stackflow-node `/counterparty/transfer` +4. indirect mode is verified via forwarding payment lookup + reveal +5. proof replay is denied within TTL window + +The SDK must work with this behavior first. + +## Client Architecture + +Runtime components in the caller: + +1. `X402HttpClient`: wraps `fetch` and handles challenge/retry flow +2. `ProofProvider`: builds direct or indirect proofs +3. `NonceCoordinator`: prevents nonce collisions across concurrent requests +4. `StateStore`: persists latest known pipe nonce/balances and replay metadata +5. `SignerAdapter`: signs Stackflow structured message payloads +6. `PipeStateSource`: optional remote source (`stackflow-node /pipes`) for + authoritative pipe state refresh + +## Request Lifecycle + +### Path A: Proactive Payment (preferred for API clients) + +1. build direct proof before first request +2. send request with `x-x402-payment` +3. if `2xx`, update local state and return +4. if `402 payment-proof-already-used` or nonce mismatch, refresh state and + retry with a new proof once + +### Path B: Challenge-Response + +1. send request without payment +2. parse `402` challenge (`payment.scheme`, required fields, amount/asset) +3. build payment proof +4. retry request once with `x-x402-payment` + +## Data Types + +```ts +export type X402PaymentMode = "direct" | "indirect"; + +export interface X402DirectProof { + mode?: "direct"; + contractId: string; + forPrincipal: string; + withPrincipal: string; + token: string | null; + amount: string; + myBalance: string; + theirBalance: string; + theirSignature: string; + nonce: string; + action: "1"; + actor: string; + hashedSecret?: string | null; + validAfter?: string | null; + beneficialOnly?: boolean; +} + +export interface X402IndirectProof { + mode: "indirect"; + paymentId: string; + secret: string; + expectedFromPrincipal: string; +} + +export type X402PaymentProof = X402DirectProof | X402IndirectProof; + +export interface X402Challenge { + ok: false; + error: "payment required"; + reason: string; + details: string; + payment: { + scheme: "x402-stackflow-v1"; + header: "x-x402-payment"; + amount: string; + asset: string; + protectedPath: string; + modes: Record; + }; +} + +export interface X402ClientOptions { + gatewayBaseUrl: string; + proactivePayment?: boolean; + maxPaymentAttempts?: number; // default 2 + paymentHeaderName?: string; // default "x-x402-payment" + requestTimeoutMs?: number; +} +``` + +## Core SDK Interfaces + +```ts +export interface ProofContext { + method: string; + path: string; + query: string; + challenge?: X402Challenge; +} + +export interface ProofProvider { + createProof(ctx: ProofContext): Promise; +} + +export interface StateStore { + getPipeState(key: string): Promise; + setPipeState(key: string, next: PipeState): Promise; + markConsumedProof(proofHash: string, expiresAtMs: number): Promise; +} + +export interface NonceCoordinator { + withPipeLock(pipeKey: string, fn: () => Promise): Promise; +} + +export interface SignerAdapter { + signStructuredMessage(input: { + domain: Record; + message: Record; + }): Promise; +} +``` + +## Reference Client Flow + +```ts +async function requestWithX402(input: RequestInfo, init?: RequestInit): Promise { + const first = await fetch(input, init); + if (first.status !== 402) return first; + + const challenge = (await first.json()) as X402Challenge; + const proof = await proofProvider.createProof({ + method: (init?.method || "GET").toUpperCase(), + path: new URL(typeof input === "string" ? input : input.url).pathname, + query: new URL(typeof input === "string" ? input : input.url).search, + challenge, + }); + + const encoded = Buffer.from(JSON.stringify(proof)).toString("base64url"); + const retryHeaders = new Headers(init?.headers || {}); + retryHeaders.set("x-x402-payment", encoded); + + return fetch(input, { ...init, headers: retryHeaders }); +} +``` + +## Nonce and Concurrency Rules + +Direct proofs must use strictly increasing per-pipe nonce. SDK should: + +1. use per-pipe lock around "read latest state -> build proof -> submit" +2. commit local nonce only after successful paid response +3. on reject (`nonce-too-low`, `payment-rejected`), refresh from source of truth +4. retry once with newer nonce + +Without lock + refresh, concurrent requests will frequently collide. + +## Error Model + +SDK should normalize gateway outcomes: + +1. `challenge_required`: 402 with valid challenge payload +2. `invalid_payment_proof`: malformed proof or schema mismatch +3. `payment_rejected`: stackflow-node rejected transfer/reveal +4. `payment_proof_replayed`: proof hash already consumed by gateway +5. `indirect_timeout`: forwarding payment not observed in time +6. `upstream_error`: payment accepted but upstream failed (`5xx`) + +## Helpful Tooling for API Clients + +Useful deliverables beyond the core SDK: + +1. CLI: `x402-call --principal ... --mode direct` +2. Local signer adapters: + - raw private key signer (server-to-server) + - wallet-bridge signer (interactive dev) + - KMS/HSM signer adapter +3. Redis-backed `StateStore` + distributed lock implementation +4. metrics hooks (attempts, challenge count, payment latency, rejects by reason) + +## Recommended Gateway Enhancements for Better Client UX + +Current gateway works without these, but SDK simplicity improves if server adds: + +1. `POST /x402/payment-intent`: return exact direct payload to sign for current + route/method and payer principal +2. stable machine-readable error `reason` values and optional retry hints +3. optional response header exposing gateway replay TTL remaining for proof hash +4. optional quote endpoint for dynamic route pricing (`method+path+tenant`) + +## Security Notes for SDK Consumers + +1. treat all remote challenge fields as untrusted input +2. bind proofs to exact method/path/query requested +3. never log raw signatures/secrets in plaintext +4. persist minimal payment state; encrypt keys at rest +5. use idempotency keys on mutating API methods regardless of x402 + +## Implementation Plan + +1. implement `packages/x402-client` with interfaces above +2. ship `fetch` middleware + in-memory state store for local dev +3. add Redis store and lock adapter for multi-instance callers +4. add integration tests against `scripts/demo-x402-e2e.js` services diff --git a/tests/stackflow-agent.test.ts b/tests/stackflow-agent.test.ts new file mode 100644 index 0000000..202b728 --- /dev/null +++ b/tests/stackflow-agent.test.ts @@ -0,0 +1,292 @@ +// @vitest-environment node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + AgentStateStore, + HourlyClosureWatcher, + StackflowAgentService, + buildPipeId, + isDisputeBeneficial, +} from "../packages/stackflow-agent/src/index.js"; + +function tempDbFile(label: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `stackflow-${label}-`)); + return path.join(dir, "agent.db"); +} + +describe("stackflow agent", () => { + it("evaluates beneficial dispute by nonce and balance policy", () => { + const should = isDisputeBeneficial({ + closureEvent: { + contractId: "ST1.contract", + pipeId: "pipe", + eventName: "force-close", + nonce: "5", + closer: "ST1OTHER", + closureMyBalance: "10", + }, + signatureState: { + forPrincipal: "ST1LOCAL", + nonce: "6", + myBalance: "50", + beneficialOnly: false, + }, + onlyBeneficial: true, + }); + expect(should).toBe(true); + }); + + it("runs hourly watcher loop and submits disputes for eligible closures", async () => { + const dbFile = tempDbFile("agent"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "8", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + let disputeCalls = 0; + const signer = { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute1" }; + }, + async sip018Sign() { + return "0x" + "33".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }; + + const agent = new StackflowAgentService({ + stateStore: store, + signer, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => [ + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "5", + closer: "ST1OTHER", + txid: "0xtx1", + blockHeight: "123", + expiresAt: "200", + closureMyBalance: "20", + }, + ], + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(true); + expect(result.scanned).toBe(1); + expect(result.disputesSubmitted).toBe(1); + expect(disputeCalls).toBe(1); + expect(store.getWatcherCursor()).toBe("123"); + + watcher.stop(); + store.close(); + }); + + it("can poll get-pipe readonly state for tracked pipes and dispute", async () => { + const dbFile = tempDbFile("agent-readonly"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + store.upsertSignatureState({ + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "8", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + let disputeCalls = 0; + const signer = { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-readonly" }; + }, + async sip018Sign() { + return "0x" + "33".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }; + + const agent = new StackflowAgentService({ + stateStore: store, + signer, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + getPipeState: async () => ({ + "balance-1": "20", + "balance-2": "80", + "expires-at": "200", + nonce: "5", + closer: "ST1OTHER", + }), + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(true); + expect(result.mode).toBe("readonly-pipe"); + expect(result.pipesScanned).toBe(1); + expect(result.closuresFound).toBe(1); + expect(result.disputesSubmitted).toBe(1); + expect(disputeCalls).toBe(1); + + watcher.stop(); + store.close(); + }); + + it("validates and signs incoming transfer requests", async () => { + const dbFile = tempDbFile("agent-sign"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: null, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const result = await agent.acceptIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(result.accepted).toBe(true); + expect(result.mySignature).toMatch(/^0x[0-9a-f]+$/); + const latest = store.getLatestSignatureState(pipeId, "ST1LOCAL"); + expect(latest?.nonce).toBe("1"); + store.close(); + }); + + it("defaults watcher interval to one hour", () => { + const dbFile = tempDbFile("agent-interval"); + const store = new AgentStateStore({ dbFile }); + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => [], + }); + expect(watcher.intervalMs).toBe(60 * 60 * 1000); + watcher.stop(); + store.close(); + }); +}); diff --git a/tests/x402-client.test.ts b/tests/x402-client.test.ts new file mode 100644 index 0000000..3d01fb5 --- /dev/null +++ b/tests/x402-client.test.ts @@ -0,0 +1,261 @@ +// @vitest-environment node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + StackflowNodePipeStateSource, + SqliteX402StateStore, + X402Client, + buildPipeStateKey, +} from "../packages/x402-client/src/index.js"; + +function createTempDbFile(label: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `stackflow-${label}-`)); + return path.join(dir, "state.db"); +} + +function buildChallengeResponse(): Response { + return new Response( + JSON.stringify({ + ok: false, + error: "payment required", + reason: "payment-header-missing", + details: "x-x402-payment header is required", + payment: { + scheme: "x402-stackflow-v1", + header: "x-x402-payment", + amount: "10", + asset: "STX", + protectedPath: "/paid-content", + modes: { + direct: { + action: "1", + requiredFields: [], + }, + }, + }, + }), + { + status: 402, + headers: { + "content-type": "application/json", + }, + }, + ); +} + +describe("x402 client scaffold", () => { + it("retries after 402 challenge with proof header", async () => { + let proofCalls = 0; + let fetchCalls = 0; + + const client = new X402Client({ + gatewayBaseUrl: "http://127.0.0.1:8790", + proactivePayment: false, + proofProvider: { + async createProof() { + proofCalls += 1; + return { + mode: "direct", + contractId: "ST1.contract", + forPrincipal: "ST1SERVER", + withPrincipal: "ST1CLIENT", + token: null, + amount: "10", + myBalance: "90", + theirBalance: "10", + theirSignature: "0x" + "11".repeat(65), + nonce: "1", + action: "1", + actor: "ST1CLIENT", + hashedSecret: null, + validAfter: null, + beneficialOnly: false, + }; + }, + }, + fetchFn: async (_url: string, init?: RequestInit) => { + fetchCalls += 1; + const headerValue = new Headers(init?.headers).get("x-x402-payment"); + if (!headerValue) { + return buildChallengeResponse(); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }); + + const response = await client.request("/paid-content", { method: "GET" }); + expect(response.status).toBe(200); + expect(fetchCalls).toBe(2); + expect(proofCalls).toBe(1); + }); + + it("supports proactive payment on first request", async () => { + let proofCalls = 0; + let fetchCalls = 0; + + const client = new X402Client({ + gatewayBaseUrl: "http://127.0.0.1:8790", + proactivePayment: true, + proofProvider: { + async createProof() { + proofCalls += 1; + return { + mode: "direct", + contractId: "ST1.contract", + forPrincipal: "ST1SERVER", + withPrincipal: "ST1CLIENT", + token: null, + amount: "10", + myBalance: "90", + theirBalance: "10", + theirSignature: "0x" + "11".repeat(65), + nonce: "2", + action: "1", + actor: "ST1CLIENT", + }; + }, + }, + fetchFn: async (_url: string, init?: RequestInit) => { + fetchCalls += 1; + const headerValue = new Headers(init?.headers).get("x-x402-payment"); + if (!headerValue) { + return buildChallengeResponse(); + } + return new Response("ok", { status: 200 }); + }, + }); + + const response = await client.request("/paid-content", { method: "GET" }); + expect(response.status).toBe(200); + expect(fetchCalls).toBe(1); + expect(proofCalls).toBe(1); + }); + + it("stores proof replay and serializes per-pipe lock", async () => { + const dbFile = createTempDbFile("x402-client"); + const store = new SqliteX402StateStore({ dbFile }); + + const proofHash = "abc123"; + store.markConsumedProof(proofHash, Date.now() + 10_000); + expect(store.isProofConsumed(proofHash)).toBe(true); + const purge = store.purgeExpired(Date.now() + 20_000); + expect(purge.consumedDeleted).toBeGreaterThanOrEqual(1); + expect(store.isProofConsumed(proofHash)).toBe(false); + + const pipeKey = buildPipeStateKey({ + contractId: "ST1.contract", + forPrincipal: "ST1SERVER", + withPrincipal: "ST1CLIENT", + token: null, + }); + let active = 0; + let maxActive = 0; + + await Promise.all([ + store.withPipeLock(pipeKey, async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 80)); + active -= 1; + }), + store.withPipeLock(pipeKey, async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 40)); + active -= 1; + }), + ]); + + expect(maxActive).toBe(1); + store.close(); + }); + + it("can fetch pipe status from stackflow-node and sync into sqlite", async () => { + const dbFile = createTempDbFile("x402-client-source"); + const store = new SqliteX402StateStore({ dbFile }); + + const source = new StackflowNodePipeStateSource({ + stackflowNodeBaseUrl: "http://127.0.0.1:8787", + fetchFn: async (url: string) => { + const parsed = new URL(url); + expect(parsed.pathname).toBe("/pipes"); + expect(parsed.searchParams.get("principal")).toBe("ST1CLIENT"); + return new Response( + JSON.stringify({ + ok: true, + pipes: [ + { + contractId: "ST1.contract", + pipeKey: { + "principal-1": "ST1CLIENT", + "principal-2": "ST1SERVER", + token: null, + }, + balance1: "50", + balance2: "25", + pending1Amount: "0", + pending2Amount: "0", + nonce: "1", + source: "onchain", + event: "fund-pipe", + updatedAt: "2026-03-03T00:00:00.000Z", + }, + { + contractId: "ST1.contract", + pipeKey: { + "principal-1": "ST1CLIENT", + "principal-2": "ST1SERVER", + token: null, + }, + balance1: "80", + balance2: "20", + pending1Amount: "0", + pending2Amount: "0", + nonce: "2", + source: "signature-state", + event: "signature-state", + updatedAt: "2026-03-03T00:00:01.000Z", + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }, + }); + + const status = await source.syncPipeState({ + principal: "ST1CLIENT", + counterpartyPrincipal: "ST1SERVER", + contractId: "ST1.contract", + stateStore: store, + }); + + expect(status.hasPipe).toBe(true); + expect(status.nonce).toBe("2"); + expect(status.myConfirmed).toBe("80"); + expect(status.theirConfirmed).toBe("20"); + + const pipeKey = buildPipeStateKey({ + contractId: "ST1.contract", + forPrincipal: "ST1CLIENT", + withPrincipal: "ST1SERVER", + token: null, + }); + const persisted = store.getPipeState(pipeKey); + expect(persisted?.nonce).toBe("2"); + expect(persisted?.myBalance).toBe("80"); + expect(persisted?.theirBalance).toBe("20"); + store.close(); + }); +}); diff --git a/vitest.node.config.js b/vitest.node.config.js new file mode 100644 index 0000000..eca4f6b --- /dev/null +++ b/vitest.node.config.js @@ -0,0 +1,15 @@ +/// + +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + environment: "node", + pool: "forks", + poolOptions: { + threads: { singleThread: true }, + forks: { singleFork: true }, + }, + setupFiles: [], + }, +}); From b1bd39711e88b2d5ed96ed5913b6b114dec381dd Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 4 Mar 2026 16:48:38 -0500 Subject: [PATCH 38/78] fix: gitleaks CI bug --- .github/workflows/secret-scan.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index d73c93e..3581ccf 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -16,9 +16,13 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 1 + # Gitleaks scans git commit ranges on PRs/pushes; shallow clones can + # miss the base commit and cause "unknown revision" failures. + fetch-depth: 0 - name: Run gitleaks uses: gitleaks/gitleaks-action@v2 with: - args: detect --source . --no-git --config .gitleaks.toml --redact + # Let the action run its default git-range scan and only pass + # scanner options. + args: --config .gitleaks.toml --redact From 3613ae54adda0286fbc32b431b71e51ef19b5b73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:50:52 +0000 Subject: [PATCH 39/78] chore(deps): bump rollup from 4.57.1 to 4.59.0 Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.59.0. - [Release notes](https://github.com/rollup/rollup/releases) - [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md) - [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.59.0) --- updated-dependencies: - dependency-name: rollup dependency-version: 4.59.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 209 +++++++++++++++++++++++----------------------- 1 file changed, 103 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab33946..836dd64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1125,9 +1125,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1138,9 +1138,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1151,9 +1151,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -1164,9 +1164,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1177,9 +1177,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1190,9 +1190,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1203,9 +1203,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1216,9 +1216,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1229,9 +1229,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -1242,9 +1242,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1255,9 +1255,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -1268,9 +1268,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1281,9 +1281,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1294,9 +1294,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1307,9 +1307,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1320,9 +1320,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1333,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1346,9 +1346,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -1359,9 +1359,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1372,9 +1372,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1385,9 +1385,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1398,9 +1398,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1411,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1424,9 +1424,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1437,9 +1437,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2074,7 +2074,6 @@ "resolved": "https://registry.npmjs.org/@stacks/clarinet-sdk/-/clarinet-sdk-3.14.0.tgz", "integrity": "sha512-lbDzK/CT/Sspb2IDsxCf9AUQQ2b7VrGFAGd1HYWlgs8Dl4Wu0gb1kXC8cSrkhzLy+lVJz6wHIbwCj2MfJE1/dA==", "license": "GPL-3.0", - "peer": true, "dependencies": { "@stacks/clarinet-sdk-wasm": "3.14.0", "@stacks/transactions": "^7.0.6", @@ -3352,9 +3351,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -3367,31 +3366,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -3569,7 +3568,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3774,7 +3772,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, From c50c27f5cfd52b27f3f828fb332697445940efb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:04:26 +0000 Subject: [PATCH 40/78] chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/xml-builder](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/xml-builder). These dependencies needed to be updated together. Updates `fast-xml-parser` from 5.3.6 to 5.4.1 - [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases) - [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.3.6...v5.4.1) Updates `@aws-sdk/xml-builder` from 3.972.5 to 3.972.9 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/xml-builder/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/xml-builder) --- updated-dependencies: - dependency-name: fast-xml-parser dependency-version: 5.4.1 dependency-type: indirect - dependency-name: "@aws-sdk/xml-builder" dependency-version: 3.972.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 836dd64..1e41846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -656,13 +656,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz", - "integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==", + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", + "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", - "fast-xml-parser": "5.3.6", + "@smithy/types": "^4.13.0", + "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" }, "engines": { @@ -1824,9 +1824,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", - "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", + "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2919,10 +2919,22 @@ "node": ">=12.0.0" } }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/fast-xml-parser": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", - "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "funding": [ { "type": "github", @@ -2931,6 +2943,7 @@ ], "license": "MIT", "dependencies": { + "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { @@ -3507,9 +3520,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", From c5dd3844ce209abfb5ee15813b905f70db6d45b0 Mon Sep 17 00:00:00 2001 From: obycode Date: Wed, 4 Mar 2026 17:18:21 -0500 Subject: [PATCH 41/78] ci: fix secret scan --- .github/workflows/secret-scan.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 3581ccf..c3122e2 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + pull-requests: read steps: - name: Checkout uses: actions/checkout@v4 @@ -26,3 +27,5 @@ jobs: # Let the action run its default git-range scan and only pass # scanner options. args: --config .gitleaks.toml --redact + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 05a970aa25be3744f97c9d71a3f24d5889f93a10 Mon Sep 17 00:00:00 2001 From: obycode Date: Thu, 5 Mar 2026 06:38:09 -0500 Subject: [PATCH 42/78] docs: add pipe console GitHub Pages --- .github/workflows/pages.yml | 40 +++ README.md | 23 ++ docs/.nojekyll | 1 + docs/app.js | 656 ++++++++++++++++++++++++++++++++++++ docs/index.html | 137 ++++++++ docs/styles.css | 164 +++++++++ 6 files changed, 1021 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/.nojekyll create mode 100644 docs/app.js create mode 100644 docs/index.html create mode 100644 docs/styles.css diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e1f0fd2 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,40 @@ +name: Deploy Pipe Console + +on: + push: + branches: ["main", "master"] + paths: + - "docs/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages Artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 83eafbf..b7342c0 100644 --- a/README.md +++ b/README.md @@ -585,6 +585,29 @@ Run an interactive browser demo (click link -> `402` -> sign -> unlock): npm run demo:x402-browser ``` +## Pipe Console (GitHub Pages) + +This repo now includes a static pipe interaction page at `docs/`: + +- `docs/index.html` +- `docs/app.js` +- `docs/styles.css` + +It includes forms/buttons for: + +1. wallet connect +2. read-only `get-pipe` +3. `fund-pipe` (open pipe) +4. `force-cancel` +5. structured transfer message signing + payload JSON builder + +To publish with GitHub Pages (no build step): + +1. go to repository Settings -> Pages +2. Source: GitHub Actions +3. push changes to `docs/` on `main` (or run `Deploy Pipe Console` manually) +4. wait for the `Deploy Pipe Console` workflow to publish + Browser demo flow: 1. open the printed local URL (gateway front door) diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/.nojekyll @@ -0,0 +1 @@ + diff --git a/docs/app.js b/docs/app.js new file mode 100644 index 0000000..5403b9b --- /dev/null +++ b/docs/app.js @@ -0,0 +1,656 @@ +import { connect, isConnected, request } from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +import { + Cl, + Pc, + cvToJSON, + deserializeCV, + principalCV, + serializeCV, +} from "https://esm.sh/@stacks/transactions@7.2.0?bundle&target=es2020"; + +const CHAIN_IDS = { + mainnet: 1n, + testnet: 2147483648n, + devnet: 2147483648n, +}; + +const DEFAULT_API_BY_NETWORK = { + mainnet: "https://api.hiro.so", + testnet: "https://api.testnet.hiro.so", + devnet: "http://127.0.0.1:3999", +}; + +const STACKFLOW_MESSAGE_VERSION = "0.6.0"; + +const elements = { + network: document.getElementById("network"), + stacksApiUrl: document.getElementById("stacks-api-url"), + contractId: document.getElementById("contract-id"), + counterparty: document.getElementById("counterparty"), + tokenContract: document.getElementById("token-contract"), + forPrincipal: document.getElementById("for-principal"), + openAmount: document.getElementById("open-amount"), + openNonce: document.getElementById("open-nonce"), + myBalance: document.getElementById("my-balance"), + theirBalance: document.getElementById("their-balance"), + transferNonce: document.getElementById("transfer-nonce"), + transferAction: document.getElementById("transfer-action"), + transferActor: document.getElementById("transfer-actor"), + transferSecret: document.getElementById("transfer-secret"), + transferValidAfter: document.getElementById("transfer-valid-after"), + walletStatus: document.getElementById("wallet-status"), + connectWallet: document.getElementById("connect-wallet"), + getPipe: document.getElementById("get-pipe"), + openPipe: document.getElementById("open-pipe"), + forceCancel: document.getElementById("force-cancel"), + signTransfer: document.getElementById("sign-transfer"), + buildPayload: document.getElementById("build-payload"), + copyOutput: document.getElementById("copy-output"), + output: document.getElementById("output"), + log: document.getElementById("log"), +}; + +const state = { + connectedAddress: null, + lastSignature: null, + lastPayload: null, +}; + +function normalizedText(value) { + return String(value ?? "").trim(); +} + +function isStacksAddress(value) { + return /^S[PMT][A-Z0-9]{38,42}$/i.test(normalizedText(value)); +} + +function nowStamp() { + return new Date().toISOString().slice(11, 19); +} + +function appendLog(message, { error = false } = {}) { + const next = `[${nowStamp()}] ${message}`; + elements.log.textContent = `${elements.log.textContent}\n${next}`.trim(); + elements.log.scrollTop = elements.log.scrollHeight; + if (error) { + console.error(`[pipe-console] ${message}`); + } else { + console.log(`[pipe-console] ${message}`); + } +} + +function setOutput(value) { + elements.output.textContent = + typeof value === "string" ? value : JSON.stringify(value, null, 2); +} + +function setWalletStatus(message, { error = false } = {}) { + elements.walletStatus.textContent = message; + elements.walletStatus.classList.toggle("error", error); +} + +function toHex(bytes) { + return Array.from(bytes) + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +function cvHex(cv) { + return `0x${toHex(serializeCV(cv))}`; +} + +function compareBytes(left, right) { + const len = Math.min(left.length, right.length); + for (let i = 0; i < len; i += 1) { + if (left[i] < right[i]) return -1; + if (left[i] > right[i]) return 1; + } + if (left.length < right.length) return -1; + if (left.length > right.length) return 1; + return 0; +} + +function canonicalPrincipals(a, b) { + const aBytes = serializeCV(principalCV(a)); + const bBytes = serializeCV(principalCV(b)); + return compareBytes(aBytes, bBytes) <= 0 + ? { principal1: a, principal2: b } + : { principal1: b, principal2: a }; +} + +function unwrapClarityJson(value) { + if (Array.isArray(value)) { + return value.map((entry) => unwrapClarityJson(entry)); + } + if (!value || typeof value !== "object") { + return value; + } + + const record = value; + const keys = Object.keys(record); + if (keys.length === 2 && keys.includes("type") && keys.includes("value")) { + const type = String(record.type || ""); + if (type === "uint" || type === "int") { + return String(record.value ?? ""); + } + if (type === "optional_none") { + return null; + } + return unwrapClarityJson(record.value); + } + + const output = {}; + for (const [key, nested] of Object.entries(record)) { + output[key] = unwrapClarityJson(nested); + } + return output; +} + +function decodeReadOnlyResult(resultHex) { + const hex = normalizedText(resultHex); + if (!hex) { + return null; + } + const decoded = deserializeCV(hex); + return unwrapClarityJson(cvToJSON(decoded)); +} + +function parseContractId() { + const raw = normalizedText(elements.contractId.value); + if (!raw.includes(".")) { + throw new Error("Contract must be ADDRESS.NAME"); + } + const [address, name] = raw.split("."); + if (!address || !name) { + throw new Error("Contract must be ADDRESS.NAME"); + } + principalCV(address); + return { contractId: raw, contractAddress: address, contractName: name }; +} + +function readNetwork() { + const network = normalizedText(elements.network.value).toLowerCase(); + if (!CHAIN_IDS[network]) { + throw new Error(`Unsupported network: ${network}`); + } + return network; +} + +function parseRequiredPrincipal(fieldName, value) { + const principal = normalizedText(value); + if (!isStacksAddress(principal)) { + throw new Error(`${fieldName} must be a valid Stacks principal`); + } + return principal; +} + +function parseOptionalTokenCV() { + const token = normalizedText(elements.tokenContract.value); + if (!token) { + return { cv: Cl.none(), tokenText: null }; + } + principalCV(token); + return { cv: Cl.some(Cl.principal(token)), tokenText: token }; +} + +function parseUintInput(fieldName, value, { min = 0n } = {}) { + const raw = normalizedText(value); + if (!/^\d+$/.test(raw)) { + throw new Error(`${fieldName} must be an unsigned integer`); + } + const parsed = BigInt(raw); + if (parsed < min) { + throw new Error(`${fieldName} must be >= ${min.toString(10)}`); + } + return parsed; +} + +function hexToBytes(value) { + const text = normalizedText(value).toLowerCase(); + const normalized = text.startsWith("0x") ? text.slice(2) : text; + if (!/^[0-9a-f]*$/.test(normalized) || normalized.length % 2 !== 0) { + throw new Error("hashed secret must be valid hex"); + } + const bytes = new Uint8Array(normalized.length / 2); + for (let i = 0; i < normalized.length; i += 2) { + bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16); + } + return bytes; +} + +function parseHashedSecretCV() { + const secret = normalizedText(elements.transferSecret.value); + if (!secret) { + return { cv: Cl.none(), text: null }; + } + const bytes = hexToBytes(secret); + if (bytes.length !== 32) { + throw new Error("hashed secret must be exactly 32 bytes"); + } + return { cv: Cl.some(Cl.buffer(bytes)), text: `0x${toHex(bytes)}` }; +} + +function parseValidAfterCV() { + const raw = normalizedText(elements.transferValidAfter.value); + if (!raw) { + return { cv: Cl.none(), text: null }; + } + const value = parseUintInput("Valid After", raw); + return { cv: Cl.some(Cl.uint(value)), text: value.toString(10) }; +} + +function extractAddress(response) { + const seen = new Set(); + const crawl = (value) => { + if (value == null) return null; + if (typeof value === "string" && isStacksAddress(value)) return value; + if (typeof value !== "object") return null; + if (seen.has(value)) return null; + seen.add(value); + + if (Array.isArray(value)) { + for (const entry of value) { + const found = crawl(entry); + if (found) return found; + } + return null; + } + + if (typeof value.address === "string" && isStacksAddress(value.address)) { + return value.address; + } + for (const nested of Object.values(value)) { + const found = crawl(nested); + if (found) return found; + } + return null; + }; + return crawl(response); +} + +function extractSignature(response) { + if (!response || typeof response !== "object") return null; + if (typeof response.signature === "string") return response.signature; + if (response.result && typeof response.result === "object") { + if (typeof response.result.signature === "string") return response.result.signature; + } + return null; +} + +function extractTxid(response) { + if (!response || typeof response !== "object") return null; + if (typeof response.txid === "string") return response.txid; + if (response.result && typeof response.result === "object") { + if (typeof response.result.txid === "string") return response.result.txid; + } + return null; +} + +async function ensureWallet({ interactive }) { + if (state.connectedAddress) { + return state.connectedAddress; + } + let connected = false; + try { + connected = await Promise.resolve(isConnected()); + } catch { + connected = false; + } + + if (!connected && interactive) { + await connect(); + } + + if (connected || interactive) { + const addresses = await request("getAddresses"); + const address = extractAddress(addresses); + if (!address) { + throw new Error("No Stacks address was returned by the wallet"); + } + state.connectedAddress = address; + elements.forPrincipal.value = elements.forPrincipal.value || address; + elements.transferActor.value = elements.transferActor.value || address; + setWalletStatus(`Connected: ${address}`); + return address; + } + + return null; +} + +function updateNetworkDefaults() { + const network = readNetwork(); + if (!normalizedText(elements.stacksApiUrl.value)) { + elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[network]; + } +} + +async function handleConnectWallet() { + try { + const address = await ensureWallet({ interactive: true }); + appendLog(`Wallet connected: ${address}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setWalletStatus(message, { error: true }); + appendLog(`Connect wallet failed: ${message}`, { error: true }); + } +} + +async function fetchReadOnly(functionName, functionArgs, sender) { + const { contractAddress, contractName } = parseContractId(); + const apiBase = normalizedText(elements.stacksApiUrl.value).replace(/\/+$/, ""); + if (!apiBase) { + throw new Error("Stacks API URL is required"); + } + const url = `${apiBase}/v2/contracts/call-read/${contractAddress}/${contractName}/${functionName}`; + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sender, + arguments: functionArgs.map((cv) => cvHex(cv)), + }), + }); + const body = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(`Read-only call failed (${response.status})`); + } + if (!body || typeof body !== "object") { + throw new Error("Read-only response was not JSON"); + } + if (body.okay === false) { + throw new Error(`Read-only call returned error: ${body.cause || "unknown"}`); + } + return body.result; +} + +async function handleGetPipe() { + try { + const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); + const forPrincipal = parseRequiredPrincipal( + "For Principal", + elements.forPrincipal.value || state.connectedAddress, + ); + const { cv: tokenCV } = parseOptionalTokenCV(); + + const resultHex = await fetchReadOnly( + "get-pipe", + [tokenCV, Cl.principal(withPrincipal)], + forPrincipal, + ); + const decoded = decodeReadOnlyResult(resultHex); + setOutput({ + call: "get-pipe", + forPrincipal, + withPrincipal, + resultHex, + decoded, + }); + appendLog("Fetched pipe state via read-only get-pipe."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setOutput(`Error: ${message}`); + appendLog(`Get pipe failed: ${message}`, { error: true }); + } +} + +function stxPostConditionForAmount(principal, amount) { + return Pc.principal(principal).willSendEq(amount).ustx(); +} + +async function callContract(functionName, functionArgs, options = {}) { + const { contractId } = parseContractId(); + const network = readNetwork(); + return request("stx_callContract", { + contract: contractId, + functionName, + functionArgs, + network, + postConditionMode: options.postConditionMode ?? "deny", + postConditions: options.postConditions ?? [], + }); +} + +async function handleOpenPipe() { + try { + const sender = await ensureWallet({ interactive: true }); + if (!sender) { + throw new Error("Connect wallet first"); + } + const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); + const amount = parseUintInput("Amount", elements.openAmount.value, { min: 1n }); + const nonceText = normalizedText(elements.openNonce.value); + const nonce = nonceText ? parseUintInput("Nonce", nonceText) : 0n; + const { cv: tokenCV, tokenText } = parseOptionalTokenCV(); + + const args = [ + tokenCV, + Cl.uint(amount), + Cl.principal(withPrincipal), + Cl.uint(nonce), + ]; + + const options = + tokenText == null + ? { + postConditionMode: "deny", + postConditions: [stxPostConditionForAmount(sender, amount)], + } + : { + postConditionMode: "allow", + postConditions: [], + }; + + const response = await callContract("fund-pipe", args, options); + const txid = extractTxid(response); + setOutput({ + action: "fund-pipe", + txid, + response, + }); + appendLog(txid ? `fund-pipe submitted: ${txid}` : "fund-pipe submitted."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setOutput(`Error: ${message}`); + appendLog(`Open pipe failed: ${message}`, { error: true }); + } +} + +async function handleForceCancel() { + try { + await ensureWallet({ interactive: true }); + const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); + const { cv: tokenCV } = parseOptionalTokenCV(); + + const response = await callContract("force-cancel", [ + tokenCV, + Cl.principal(withPrincipal), + ]); + const txid = extractTxid(response); + setOutput({ + action: "force-cancel", + txid, + response, + }); + appendLog(txid ? `force-cancel submitted: ${txid}` : "force-cancel submitted."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setOutput(`Error: ${message}`); + appendLog(`Force cancel failed: ${message}`, { error: true }); + } +} + +function buildTransferContext() { + const network = readNetwork(); + const { contractId } = parseContractId(); + const forPrincipal = parseRequiredPrincipal( + "For Principal", + elements.forPrincipal.value || state.connectedAddress, + ); + const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); + const actor = parseRequiredPrincipal( + "Actor Principal", + elements.transferActor.value || forPrincipal, + ); + const myBalance = parseUintInput("My Balance", elements.myBalance.value); + const theirBalance = parseUintInput("Their Balance", elements.theirBalance.value); + const nonce = parseUintInput("Nonce", elements.transferNonce.value); + const action = parseUintInput("Action", elements.transferAction.value); + const { cv: tokenCV, tokenText } = parseOptionalTokenCV(); + const { cv: hashedSecretCV, text: hashedSecretText } = parseHashedSecretCV(); + const { cv: validAfterCV, text: validAfterText } = parseValidAfterCV(); + + const pair = canonicalPrincipals(forPrincipal, withPrincipal); + const balance1 = pair.principal1 === forPrincipal ? myBalance : theirBalance; + const balance2 = pair.principal1 === forPrincipal ? theirBalance : myBalance; + + const domain = Cl.tuple({ + name: Cl.stringAscii(contractId), + version: Cl.stringAscii(STACKFLOW_MESSAGE_VERSION), + "chain-id": Cl.uint(CHAIN_IDS[network]), + }); + + const message = Cl.tuple({ + token: tokenCV, + "principal-1": Cl.principal(pair.principal1), + "principal-2": Cl.principal(pair.principal2), + "balance-1": Cl.uint(balance1), + "balance-2": Cl.uint(balance2), + nonce: Cl.uint(nonce), + action: Cl.uint(action), + actor: Cl.principal(actor), + "hashed-secret": hashedSecretCV, + "valid-after": validAfterCV, + }); + + return { + network, + contractId, + forPrincipal, + withPrincipal, + token: tokenText, + myBalance: myBalance.toString(10), + theirBalance: theirBalance.toString(10), + nonce: nonce.toString(10), + action: action.toString(10), + actor, + hashedSecret: hashedSecretText, + validAfter: validAfterText, + domain, + message, + }; +} + +async function handleSignTransfer() { + try { + await ensureWallet({ interactive: true }); + const context = buildTransferContext(); + const response = await request("stx_signStructuredMessage", { + domain: context.domain, + message: context.message, + }); + const signature = extractSignature(response); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } + state.lastSignature = signature; + + const payload = { + contractId: context.contractId, + forPrincipal: context.forPrincipal, + withPrincipal: context.withPrincipal, + token: context.token, + myBalance: context.myBalance, + theirBalance: context.theirBalance, + nonce: context.nonce, + action: context.action, + actor: context.actor, + hashedSecret: context.hashedSecret, + validAfter: context.validAfter, + theirSignature: signature, + }; + state.lastPayload = payload; + setOutput(payload); + appendLog("Structured transfer message signed."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setOutput(`Error: ${message}`); + appendLog(`Sign transfer failed: ${message}`, { error: true }); + } +} + +function handleBuildPayload() { + try { + const context = buildTransferContext(); + const payload = { + contractId: context.contractId, + forPrincipal: context.forPrincipal, + withPrincipal: context.withPrincipal, + token: context.token, + myBalance: context.myBalance, + theirBalance: context.theirBalance, + nonce: context.nonce, + action: context.action, + actor: context.actor, + hashedSecret: context.hashedSecret, + validAfter: context.validAfter, + theirSignature: state.lastSignature, + }; + state.lastPayload = payload; + setOutput(payload); + appendLog("Built transfer payload JSON."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setOutput(`Error: ${message}`); + appendLog(`Build payload failed: ${message}`, { error: true }); + } +} + +async function handleCopyOutput() { + try { + await navigator.clipboard.writeText(elements.output.textContent || ""); + appendLog("Copied output to clipboard."); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendLog(`Copy failed: ${message}`, { error: true }); + } +} + +function wireEvents() { + elements.connectWallet.addEventListener("click", handleConnectWallet); + elements.getPipe.addEventListener("click", handleGetPipe); + elements.openPipe.addEventListener("click", handleOpenPipe); + elements.forceCancel.addEventListener("click", handleForceCancel); + elements.signTransfer.addEventListener("click", handleSignTransfer); + elements.buildPayload.addEventListener("click", handleBuildPayload); + elements.copyOutput.addEventListener("click", handleCopyOutput); + elements.network.addEventListener("change", () => { + elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[readNetwork()]; + }); +} + +async function bootstrap() { + wireEvents(); + updateNetworkDefaults(); + try { + const address = await ensureWallet({ interactive: false }); + if (address) { + appendLog(`Restored wallet session: ${address}`); + } else { + appendLog("No active wallet session."); + } + } catch (error) { + appendLog( + `Wallet session restore failed: ${ + error instanceof Error ? error.message : String(error) + }`, + { error: true }, + ); + } +} + +bootstrap().catch((error) => { + appendLog( + `Fatal startup error: ${error instanceof Error ? error.message : String(error)}`, + { error: true }, + ); +}); diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..bf33b9b --- /dev/null +++ b/docs/index.html @@ -0,0 +1,137 @@ + + + + + + Stackflow Pipe Console + + + +
+
+

Stackflow Tools

+

Pipe Console

+

+ A browser utility page for common pipe operations: connect wallet, read + pipe state, open a pipe, force-cancel, and sign transfer messages. +

+
+ +
+

Config

+
+ + + + + + +
+
+ +
+

Wallet

+

Wallet not connected.

+
+ +
+
+ +
+

Pipe Actions

+
+ + + +
+
+ + + +
+
+ +
+

Transfer Signer

+
+ + + + + + + +
+
+ + + +
+
+ +
+

Output

+
Ready.
+
+ +
+

Log

+
Ready.
+
+
+ + + + diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..5eaa13b --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,164 @@ +:root { + color-scheme: light; + --bg: #f4f6f2; + --ink: #162018; + --muted: #4e6050; + --line: #cfd8cb; + --card: #ffffff; + --accent: #136a4a; + --accent-ink: #f3fff8; + --danger: #9f2f2f; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(1000px 400px at 100% 0%, #e4ecdf 0%, transparent 80%), + radial-gradient(700px 300px at 0% 10%, #e9ede2 0%, transparent 70%), + var(--bg); +} + +.page { + max-width: 980px; + margin: 0 auto; + padding: 1.25rem 1rem 2.5rem; + display: grid; + gap: 0.95rem; +} + +.hero { + border-radius: 1rem; + background: linear-gradient(120deg, #204835 0%, #2d6b4b 100%); + color: #f2f8f4; + padding: 1.2rem; +} + +.eyebrow { + margin: 0 0 0.4rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + opacity: 0.9; +} + +.hero h1 { + margin: 0; + font-size: 1.5rem; +} + +.sub { + margin: 0.6rem 0 0; + line-height: 1.45; + max-width: 72ch; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 1rem; + padding: 1rem; +} + +.card h2 { + margin: 0 0 0.75rem; + font-size: 1.06rem; +} + +.grid { + display: grid; + gap: 0.65rem; +} + +.grid.two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.grid.three { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +label { + display: grid; + gap: 0.35rem; + font-size: 0.86rem; + color: var(--muted); +} + +input, +select, +button { + font: inherit; +} + +input, +select { + border: 1px solid var(--line); + border-radius: 0.55rem; + padding: 0.55rem 0.65rem; + color: var(--ink); + background: #fff; +} + +input:disabled { + background: #f7faf6; + color: #778579; +} + +.actions { + margin-top: 0.8rem; + display: flex; + gap: 0.55rem; + flex-wrap: wrap; +} + +button { + border: 0; + border-radius: 0.62rem; + padding: 0.58rem 0.88rem; + background: var(--accent); + color: var(--accent-ink); + cursor: pointer; +} + +button:hover { + filter: brightness(1.05); +} + +#force-cancel { + background: #8b4a1f; +} + +#copy-output { + background: #2c5c86; +} + +.terminal { + margin: 0; + padding: 0.85rem; + border-radius: 0.62rem; + background: #15241a; + color: #ccf3d7; + min-height: 7rem; + max-height: 24rem; + overflow: auto; + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 0.86rem; + line-height: 1.36; +} + +#wallet-status.error { + color: var(--danger); +} + +@media (max-width: 860px) { + .grid.two, + .grid.three { + grid-template-columns: 1fr; + } +} From c8e2a90c7cb3be4622c0dc1b55cbd6fb506220da Mon Sep 17 00:00:00 2001 From: obycode Date: Thu, 5 Mar 2026 07:19:00 -0500 Subject: [PATCH 43/78] feat: add bns support and default contracts --- README.md | 2 + docs/app.js | 261 ++++++++++++++++++++++++++++++++++++++++++++---- docs/index.html | 14 ++- 3 files changed, 254 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b7342c0..251612b 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,8 @@ It includes forms/buttons for: 3. `fund-pipe` (open pipe) 4. `force-cancel` 5. structured transfer message signing + payload JSON builder +6. principal resolution for `.btc` names (for example `brice.btc`) via BNSv2 API +7. preset Stackflow contract selection with token auto-fill for official STX/sBTC mainnet contracts To publish with GitHub Pages (no build step): diff --git a/docs/app.js b/docs/app.js index 5403b9b..6a66bbd 100644 --- a/docs/app.js +++ b/docs/app.js @@ -21,11 +21,23 @@ const DEFAULT_API_BY_NETWORK = { }; const STACKFLOW_MESSAGE_VERSION = "0.6.0"; +const BNSV2_API_BASE = "https://api.bnsv2.com"; +const CONTRACT_PRESETS = { + "stx-mainnet": { + contractId: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-0-6-0", + tokenContract: "", + }, + "sbtc-mainnet": { + contractId: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0", + tokenContract: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token", + }, +}; const elements = { network: document.getElementById("network"), stacksApiUrl: document.getElementById("stacks-api-url"), contractId: document.getElementById("contract-id"), + contractPreset: document.getElementById("contract-preset"), counterparty: document.getElementById("counterparty"), tokenContract: document.getElementById("token-contract"), forPrincipal: document.getElementById("for-principal"), @@ -54,6 +66,7 @@ const state = { connectedAddress: null, lastSignature: null, lastPayload: null, + nameCache: new Map(), }; function normalizedText(value) { @@ -64,6 +77,32 @@ function isStacksAddress(value) { return /^S[PMT][A-Z0-9]{38,42}$/i.test(normalizedText(value)); } +function isPrincipalText(value) { + const text = normalizedText(value); + if (!text || !/^S/i.test(text)) { + return false; + } + try { + principalCV(text); + return true; + } catch { + return false; + } +} + +function getStacksApiBase() { + const apiBase = normalizedText(elements.stacksApiUrl.value).replace(/\/+$/, ""); + if (!apiBase) { + throw new Error("Stacks API URL is required"); + } + return apiBase; +} + +function looksLikeBtcName(value) { + const text = normalizedText(value).toLowerCase(); + return /^[a-z0-9][a-z0-9-]{0,36}\.btc$/.test(text); +} + function nowStamp() { return new Date().toISOString().slice(11, 19); } @@ -176,12 +215,131 @@ function readNetwork() { return network; } -function parseRequiredPrincipal(fieldName, value) { - const principal = normalizedText(value); - if (!isStacksAddress(principal)) { - throw new Error(`${fieldName} must be a valid Stacks principal`); +function extractPrincipalFromNamePayload(payload) { + const visited = new Set(); + const crawl = (value) => { + if (value == null) { + return null; + } + if (typeof value === "string") { + return isPrincipalText(value) ? value : null; + } + if (typeof value !== "object") { + return null; + } + if (visited.has(value)) { + return null; + } + visited.add(value); + + if (Array.isArray(value)) { + for (const entry of value) { + const found = crawl(entry); + if (found) { + return found; + } + } + return null; + } + + const priorityKeys = [ + "address", + "owner", + "owner_address", + "ownerAddress", + "current_owner", + "principal", + ]; + for (const key of priorityKeys) { + if (key in value) { + const found = crawl(value[key]); + if (found) { + return found; + } + } + } + for (const nested of Object.values(value)) { + const found = crawl(nested); + if (found) { + return found; + } + } + return null; + }; + + return crawl(payload); +} + +async function resolveBtcNameToPrincipal(name) { + const normalizedName = normalizedText(name).toLowerCase(); + const network = readNetwork(); + const cacheKey = `${network}:${normalizedName}`; + const cached = state.nameCache.get(cacheKey); + if (cached) { + return cached; + } + + const encoded = encodeURIComponent(normalizedName); + const endpoints = + network === "mainnet" + ? [`${BNSV2_API_BASE}/names/${encoded}`] + : network === "testnet" + ? [`${BNSV2_API_BASE}/testnet/names/${encoded}`] + : [`${BNSV2_API_BASE}/testnet/names/${encoded}`, `${BNSV2_API_BASE}/names/${encoded}`]; + + const failures = []; + for (const endpoint of endpoints) { + try { + const response = await fetch(endpoint, { + headers: { accept: "application/json" }, + }); + if (response.status === 404) { + failures.push(`${response.status} ${endpoint}`); + continue; + } + if (!response.ok) { + failures.push(`${response.status} ${endpoint}`); + continue; + } + const body = await response.json().catch(() => null); + const principal = extractPrincipalFromNamePayload(body); + if (principal) { + state.nameCache.set(cacheKey, principal); + return principal; + } + failures.push(`no-principal ${endpoint}`); + } catch (error) { + failures.push( + `error ${endpoint}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + throw new Error( + `Could not resolve ${normalizedName}. Tried: ${failures.slice(0, 3).join(" | ")}`, + ); +} + +async function resolvePrincipalInput(fieldName, value, { required = true } = {}) { + const input = normalizedText(value); + if (!input) { + if (required) { + throw new Error(`${fieldName} is required`); + } + return null; + } + + if (isPrincipalText(input)) { + return input; } - return principal; + + if (looksLikeBtcName(input)) { + const principal = await resolveBtcNameToPrincipal(input); + appendLog(`${fieldName}: resolved ${input} -> ${principal}`); + return principal; + } + + throw new Error(`${fieldName} must be a Stacks principal or .btc name`); } function parseOptionalTokenCV() { @@ -324,6 +482,36 @@ function updateNetworkDefaults() { } } +function getPresetKeyByValues(contractId, tokenContract) { + const contractText = normalizedText(contractId); + const tokenText = normalizedText(tokenContract); + for (const [presetKey, preset] of Object.entries(CONTRACT_PRESETS)) { + if ( + normalizedText(preset.contractId) === contractText && + normalizedText(preset.tokenContract) === tokenText + ) { + return presetKey; + } + } + return "custom"; +} + +function applyContractPreset(presetKey, { log = true } = {}) { + const preset = CONTRACT_PRESETS[presetKey]; + if (!preset) { + return; + } + elements.contractId.value = preset.contractId; + elements.tokenContract.value = preset.tokenContract; + if (log) { + appendLog( + `Applied preset ${presetKey}: contract=${preset.contractId}, token=${ + preset.tokenContract || "(none)" + }`, + ); + } +} + async function handleConnectWallet() { try { const address = await ensureWallet({ interactive: true }); @@ -337,10 +525,7 @@ async function handleConnectWallet() { async function fetchReadOnly(functionName, functionArgs, sender) { const { contractAddress, contractName } = parseContractId(); - const apiBase = normalizedText(elements.stacksApiUrl.value).replace(/\/+$/, ""); - if (!apiBase) { - throw new Error("Stacks API URL is required"); - } + const apiBase = getStacksApiBase(); const url = `${apiBase}/v2/contracts/call-read/${contractAddress}/${contractName}/${functionName}`; const response = await fetch(url, { method: "POST", @@ -365,8 +550,11 @@ async function fetchReadOnly(functionName, functionArgs, sender) { async function handleGetPipe() { try { - const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); - const forPrincipal = parseRequiredPrincipal( + const withPrincipal = await resolvePrincipalInput( + "Counterparty", + elements.counterparty.value, + ); + const forPrincipal = await resolvePrincipalInput( "For Principal", elements.forPrincipal.value || state.connectedAddress, ); @@ -416,7 +604,10 @@ async function handleOpenPipe() { if (!sender) { throw new Error("Connect wallet first"); } - const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); + const withPrincipal = await resolvePrincipalInput( + "Counterparty", + elements.counterparty.value, + ); const amount = parseUintInput("Amount", elements.openAmount.value, { min: 1n }); const nonceText = normalizedText(elements.openNonce.value); const nonce = nonceText ? parseUintInput("Nonce", nonceText) : 0n; @@ -458,7 +649,10 @@ async function handleOpenPipe() { async function handleForceCancel() { try { await ensureWallet({ interactive: true }); - const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); + const withPrincipal = await resolvePrincipalInput( + "Counterparty", + elements.counterparty.value, + ); const { cv: tokenCV } = parseOptionalTokenCV(); const response = await callContract("force-cancel", [ @@ -479,15 +673,18 @@ async function handleForceCancel() { } } -function buildTransferContext() { +async function buildTransferContext() { const network = readNetwork(); const { contractId } = parseContractId(); - const forPrincipal = parseRequiredPrincipal( + const forPrincipal = await resolvePrincipalInput( "For Principal", elements.forPrincipal.value || state.connectedAddress, ); - const withPrincipal = parseRequiredPrincipal("Counterparty", elements.counterparty.value); - const actor = parseRequiredPrincipal( + const withPrincipal = await resolvePrincipalInput( + "Counterparty", + elements.counterparty.value, + ); + const actor = await resolvePrincipalInput( "Actor Principal", elements.transferActor.value || forPrincipal, ); @@ -543,7 +740,7 @@ function buildTransferContext() { async function handleSignTransfer() { try { await ensureWallet({ interactive: true }); - const context = buildTransferContext(); + const context = await buildTransferContext(); const response = await request("stx_signStructuredMessage", { domain: context.domain, message: context.message, @@ -578,9 +775,9 @@ async function handleSignTransfer() { } } -function handleBuildPayload() { +async function handleBuildPayload() { try { - const context = buildTransferContext(); + const context = await buildTransferContext(); const payload = { contractId: context.contractId, forPrincipal: context.forPrincipal, @@ -626,11 +823,35 @@ function wireEvents() { elements.network.addEventListener("change", () => { elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[readNetwork()]; }); + elements.contractPreset.addEventListener("change", () => { + applyContractPreset(elements.contractPreset.value); + }); + elements.contractId.addEventListener("input", () => { + elements.contractPreset.value = getPresetKeyByValues( + elements.contractId.value, + elements.tokenContract.value, + ); + }); + elements.tokenContract.addEventListener("input", () => { + elements.contractPreset.value = getPresetKeyByValues( + elements.contractId.value, + elements.tokenContract.value, + ); + }); } async function bootstrap() { wireEvents(); updateNetworkDefaults(); + if (!normalizedText(elements.contractId.value) && !normalizedText(elements.tokenContract.value)) { + elements.contractPreset.value = "stx-mainnet"; + applyContractPreset("stx-mainnet", { log: false }); + } else { + elements.contractPreset.value = getPresetKeyByValues( + elements.contractId.value, + elements.tokenContract.value, + ); + } try { const address = await ensureWallet({ interactive: false }); if (address) { diff --git a/docs/index.html b/docs/index.html index bf33b9b..89629dc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -36,9 +36,17 @@

Config

Stackflow Contract (ADDRESS.NAME) +
@@ -103,7 +111,7 @@

Transfer Signer