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/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..c3122e2 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,31 @@ +name: Secret Scan + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + gitleaks: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # 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: + # 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 }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c13be58 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: ["main", "master"] + pull_request: + +jobs: + test-clarity: + name: Clarity contract tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run clarity tests + run: npm run test:clarity + + test-node: + name: Node tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run node tests + run: npm run test:node diff --git a/.gitignore b/.gitignore index f2e80a1..660f873 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ coverage costs-reports.json node_modules .debug_history +**/.env +server/data/* +server/dist/ 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/Clarinet.toml b/Clarinet.toml index 45fa451..efb441f 100644 --- a/Clarinet.toml +++ b/Clarinet.toml @@ -9,26 +9,36 @@ cache_dir = './.cache' contract_id = 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard' [[project.requirements]] -contract_id = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.sip-010-trait-ft-standard' +contract_id = "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0" + +[[project.requirements]] +contract_id = "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-deposit" + [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-sbtc] +path = 'contracts/stackflow.clar' +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/README.md b/README.md index aacfcb1..1d93800 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 @@ -202,6 +204,608 @@ 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 Stackflow Node Server (Event Observer) + +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 stackflow-node +``` + +Optional environment variables: + +```bash +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 +STACKFLOW_NODE_LOG_RAW_EVENTS=false +STACKFLOW_CONTRACTS=ST....stackflow-0-6-0,ST....stackflow-sbtc-0-6-0 +STACKFLOW_NODE_PRINCIPALS=ST...,ST... +STACKS_NETWORK=devnet +STACKS_API_URL=http://localhost:20443 +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 +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 +contract identifier matching `*.stackflow*`. +When set, `STACKFLOW_CONTRACTS` entries are trimmed of whitespace and matched case-sensitively (Stacks contract identifiers are case-sensitive). +The current implementation uses Node's `node:sqlite` module for persistence. +`STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE` supports `readonly` (default), +`accept-all`, and `reject-all`. Non-`readonly` modes are intended for testing. +`STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE` supports `auto` (default), `noop`, and +`mock`. `mock` is intended for local integration testing. +`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. +`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. +`STACKFLOW_NODE_PORT` must be a valid TCP port (`1-65535`) and fails fast on +invalid values. +`STACKFLOW_NODE_MAX_RECENT_EVENTS` is clamped to at least `1` so event pruning +cannot be disabled accidentally via negative values. +Boolean env vars accept `true/false`, `1/0`, `yes/no`, and `on/off` +(case-insensitive); invalid boolean text now fails fast to prevent silent +misconfiguration. +Integer env vars must be plain integer text (for example `10000`, not `10s` +or `12.5`); malformed numeric values fail fast instead of being silently +coerced. +`STACKS_NETWORK` accepts only `mainnet`, `testnet`, `devnet`, or `mocknet` +(case-insensitive); invalid values fail fast instead of silently defaulting. +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 +2. processes closure events for pipes that include at least one listed 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: + +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) +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: + +1. `POST /counterparty/transfer` +2. `POST /counterparty/signature-request` + +`/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. 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 +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` 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: + +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 stackflow-node stores one latest state per `(contract, pipe, forPrincipal)`, +replacing only when the incoming nonce is strictly greater. +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 the effective watchlist, the request returns `403`. +If the per-IP write limit is exceeded, the request returns `429`. + +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 stackflow-node 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"] +``` + +Devnet contracts and initialization plan: + +1. deployment plan: `deployments/default.devnet-plan.yaml` (publishes `stackflow` and `stackflow-sbtc`) +2. the same devnet plan also runs post-deploy `init` contract calls: + - `stackflow` with `none` token (STX mode) + - `stackflow-sbtc` with `ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-token` +3. optional/manual fallback: `./init-stackflow.sh` +4. full plan details: `deployments/DEVNET_STACKFLOW_SBTC_PLAN.md` + +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 + +## 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` +- `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: + +```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 +``` + +## 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 +6. principal resolution for `.btc` names (for example `brice.btc`) via BNSv2 API +7. network-aware preset Stackflow contract selection with token auto-fill: + - devnet: `ST1...stackflow` and `ST1...stackflow-sbtc` + - mainnet: official `stackflow-0-6-0` and `stackflow-sbtc-0-6-0` + +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) +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) + +## 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` + +## Testing commands + +Use these commands depending on what changed: + +1. Full project checks (Clarity + Node suites): + +```bash +npm test +``` + +2. Clarity contract tests only: + +```bash +npm run test:clarity +``` + +3. Node/agent/x402 suites only: + +```bash +npm run test:node +``` + +Integration tests for the HTTP server are opt-in (they spawn a real process and +bind a local port): + +```bash +npm run test:stackflow-node:http +``` + # Reference Server Implementation As discussed in the details, users of Stackflow should run a server to keep diff --git a/contracts/reservoir.clar b/contracts/reservoir.clar index 15de24f..9409f9f 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 @@ -23,47 +23,382 @@ ;; 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 '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)) (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)) +(define-constant ERR_UNAPPROVED_TOKEN (err u207)) +(define-constant ERR_INCORRECT_STACKFLOW (err u208)) +(define-constant ERR_AMOUNT_NOT_AVAILABLE (err u209)) -;;; Deposit `amount` funds into an unfunded tap for FT `token` (`none` -;;; indicates STX). Create the tap if one does not already exist. +;;; 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) + +;;; 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) + +;;; The StackFlow contract that this Reservoir is registered with. +(define-data-var stackflow-contract (optional principal) none) + +;;; 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, + } +) + +;; ----- 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 )) + (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)) + + ;; 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? () (try! (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)) + ) +) + +;;; 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)) + ) +) + +;;; 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: +;;; - `(ok true)` on success +;;; - `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)) + (unwrap! (transfer-to-contract token amount) ERR_FUNDING_FAILED) + (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 (unwrap-panic (get-available-liquidity token))) + ERR_AMOUNT_NOT_AVAILABLE + ) + + ;; Perform the withdrawal. + (unwrap! (transfer-from-contract token amount recipient) ERR_TRANSFER_FAILED) + + (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) + )) + ) + (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. + (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) + )) + ) + (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. + (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 + )) + ) + ) +) + +;;; 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) + )) + ) + (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) + + (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 +;;; `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) - (with principal) (nonce uint) ) - (contract-call? .stackflow fund-pipe token amount RESERVOIR nonce) + (begin + (try! (check-valid stackflow token)) + (contract-call? stackflow fund-pipe token amount RESERVOIR nonce) + ) +) + +;;; 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 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 +;;; -`(ok expire-block)` on success +;;; - `ERR_AMOUNT_NOT_AVAILABLE` if reservoir liquidity is below `amount` ;;; - `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 )) @@ -73,59 +408,184 @@ (reservoir-signature (buff 65)) (nonce uint) ) - (let ((borrower tx-sender)) - (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)) - ) - ERR_BORROW_FEE_PAYMENT_FAILED + (let ( + (borrower tx-sender) + (expected-fee (get-borrow-fee amount)) + (available-liquidity (unwrap-panic (get-available-liquidity token))) + (until (+ burn-block-height BORROW_TERM_BLOCKS)) ) - (as-contract (contract-call? .stackflow deposit amount token borrower reservoir-balance - my-balance reservoir-signature my-signature nonce + (try! (check-valid stackflow token)) + (asserts! (<= amount available-liquidity) ERR_AMOUNT_NOT_AVAILABLE) + (asserts! (>= fee expected-fee) ERR_INVALID_FEE) + (if (is-eq fee u0) + true + (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 + 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 { + amount: amount, + until: until, + }) + + (ok until) ) ) -;;; As the operator, add `amount` of STX or FT `token` to the reservoir for -;;; borrowing. +;;; Create a new tap and immediately borrow liquidity from the reservoir in a +;;; single transaction. This is a convenience function combining `create-tap` +;;; and `borrow-liquidity` for the common setup case where a new participant +;;; wants both outgoing capacity (their own deposit) and incoming capacity +;;; (borrowed liquidity) at once. +;;; +;;; The caller must first obtain `reservoir-signature` from the reservoir +;;; operator off-chain, confirming the post-borrow balances. +;;; +;;; Parameters: +;;; - `stackflow`: the StackFlow token contract +;;; - `token`: optional SIP-010 token (none for STX) +;;; - `tap-amount`: amount the caller deposits to fund their sending side +;;; - `tap-nonce`: nonce for the initial tap creation (typically u0) +;;; - `borrow-amount`: amount of liquidity to borrow from the reservoir +;;; - `borrow-fee`: fee paid to the reservoir for the borrow +;;; (must be >= get-borrow-fee(borrow-amount)) +;;; - `my-balance`: caller's balance after the borrow deposit +;;; (should equal tap-amount since no transfers have occurred yet) +;;; - `reservoir-balance`: reservoir's balance after the borrow deposit +;;; (should equal borrow-amount) +;;; - `my-signature`: caller's SIP-018 signature over the post-borrow state +;;; - `reservoir-signature`: reservoir's SIP-018 signature over the post-borrow state +;;; - `borrow-nonce`: nonce for the borrow deposit (must be > tap-nonce) +;;; ;;; Returns: -;;; - `(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)) +;;; - `(ok expire-block)` on success, where expire-block is the burn block +;;; height at which the borrowed liquidity expires +;;; - Any error from `create-tap` or `borrow-liquidity` +(define-public (create-tap-with-borrowed-liquidity + (stackflow ) + (token (optional )) + (tap-amount uint) + (tap-nonce uint) + (borrow-amount uint) + (borrow-fee uint) + (my-balance uint) + (reservoir-balance uint) + (my-signature (buff 65)) + (reservoir-signature (buff 65)) + (borrow-nonce 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) - (stx-transfer? amount tx-sender (as-contract tx-sender)) - ) - ERR_FUNDING_FAILED + (try! (create-tap stackflow token tap-amount tap-nonce)) + (borrow-liquidity stackflow borrow-amount borrow-fee token my-balance + reservoir-balance my-signature reservoir-signature borrow-nonce) + ) +) + +;; ----- Read-only functions ----- + +;;; 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 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 ----- + +;;; Transfer `amount` of `token` from this contract to `recipient`. +(define-private (transfer-from-contract + (token (optional )) + (amount uint) + (recipient principal) + ) + (match token + t (as-contract? ((with-ft (contract-of t) "*" amount)) + (try! (contract-call? t transfer amount tx-sender recipient none)) + ) + (as-contract? ((with-stx amount)) + (try! (stx-transfer? amount tx-sender recipient)) + ) + ) +) + +;;; 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 + t (some (contract-of t)) + none + ) +) + +;;; Check if the Reservoir is initialized and the correct stackflow and token +;;; contracts are passed. +(define-private (check-valid + (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) ) ) -;;; As the operator, remove `amount` of STX or FT `token` from the reservoir. -;;; Returns: -;;; - `(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)) +;;; Check if the Reservoir is initialized and the correct StackFlow contract +;;; is passed. +(define-private (check-valid-stackflow (stackflow )) (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) - ) - ) - ERR_TRANSFER_FAILED + (asserts! (var-get initialized) ERR_NOT_INITIALIZED) + (asserts! (is-eq (some (contract-of stackflow)) (var-get stackflow-contract)) + ERR_INCORRECT_STACKFLOW + ) + (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/contracts/stackflow-token.clar b/contracts/stackflow-token.clar index 8362d67..836671f 100644 --- a/contracts/stackflow-token.clar +++ b/contracts/stackflow-token.clar @@ -28,100 +28,120 @@ (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 ( + (register-agent + ( + principal ;; agent ) - (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 bool uint) + ) + (deregister-agent + () + (response bool uint) + ) + (fund-pipe + ( + (optional ) ;; token + uint ;; amount + principal ;; with + uint ;; nonce ) - (force-cancel - ( - (optional ) ;; token - principal ;; with - ) - (response uint 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 + ) + (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/stackflow.clar b/contracts/stackflow.clar index 7c7e4c8..15bd754 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 @@ -29,22 +29,19 @@ ;; 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 CONTRACT_DEPLOYER tx-sender) (define-constant MAX_HEIGHT u340282366920938463463374607431768211455) (define-constant WAITING_PERIOD u144) ;; 24 hours in blocks ;; 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: (unwrap-panic (to-ascii? current-contract)), + version: "0.6.0", + chain-id: chain-id, +})))) (define-constant structured-data-header (concat structured-data-prefix message-domain-hash)) ;; Actions @@ -61,7 +58,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)) @@ -79,6 +75,9 @@ (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)) +(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) @@ -92,22 +91,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 ;; @@ -120,31 +131,32 @@ (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)) ) ) -;;; 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 @@ -160,36 +172,36 @@ ;;; - `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 - ( + (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)) - (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)) + (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, + } + )) + (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! (>= (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) @@ -197,8 +209,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,61 +245,27 @@ (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)) + (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, expires-at: MAX_HEIGHT, nonce: nonce, - closer: none + 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 - 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,14 +292,16 @@ ;;; 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)) (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) @@ -329,14 +310,17 @@ (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,13 +362,12 @@ (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)) (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) @@ -393,8 +376,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 +393,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 ) - - (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)) + (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, @@ -428,34 +410,16 @@ 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,30 +464,17 @@ (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 ) ) -;;; 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 @@ -534,7 +485,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) @@ -548,25 +499,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 ) ) @@ -579,10 +513,31 @@ ;;; - `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 - ( - (pipe-key (try! (get-pipe-key (contract-of-optional token) tx-sender with))) +(define-public (finalize + (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) for with))) (pipe (unwrap! (map-get? pipes pipe-key) ERR_NO_SUCH_PIPE)) (closer (get closer pipe)) (expires-at (get expires-at pipe)) @@ -591,7 +546,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)) @@ -604,12 +559,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) ) ) ) @@ -618,7 +569,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 @@ -637,118 +588,68 @@ (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)) - - ;; 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. - (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)) - - ;; 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) - ) + (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 )) - (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) + )) + (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, + })) ) - ERR_INVALID_TOTAL_BALANCE - ) + ;; Update the pipe with the new balances and nonce. + (map-set pipes pipe-key result-pipe) - ;; 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) + ) ) ) @@ -756,15 +657,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 )) @@ -775,75 +676,32 @@ (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)) + (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)) - (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) - - ;; 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 + (settled-pipe (settle-pending pipe)) + (updated-pipe (merge settled-pipe { + balance-1: balance-1, + balance-2: balance-2, + nonce: nonce, + })) ) + ;; 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 - ) + (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)) @@ -857,6 +715,7 @@ my-signature: my-signature, their-signature: their-signature, }) + (ok pipe-key) ) ) @@ -872,13 +731,24 @@ ;;; (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) ;;; }) ;;; ``` ;;; - `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 +761,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,35 +774,43 @@ (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))) ) ) -;;; 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) - (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,17 +819,106 @@ (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 + (let ( + (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! (balance-check pipe-key balance-1 balance-2 valid-after)) + (try! (nonce-check pipe-key nonce)) + (try! (verify-hash-signature hash signature signer)) + (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-signature signature signer pipe-key balance-1 balance-2 nonce action + actor hashed-secret valid-after + ) + ) +) + +;;; 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 - ) false))) - (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) + ) ) ) @@ -955,15 +926,20 @@ ;;; `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) (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 +949,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) + (unwrap! (verify-hash-signature hash signature-1 signer-1) + ERR_INVALID_SENDER_SIGNATURE + ) + (unwrap! (verify-hash-signature hash signature-2 signer-2) + ERR_INVALID_OTHER_SIGNATURE + ) (ok true) ) ) @@ -997,8 +974,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,19 +982,50 @@ ;;; 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, + } )) ) ) +;;; 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: @@ -1027,51 +1034,56 @@ ;;; - `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) ) - (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 (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 current-contract none) + ERR_DEPOSIT_FAILED + ) + (unwrap! (stx-transfer? amount tx-sender current-contract) + 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 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 { - amount: amount, - burn-height: (+ burn-block-height CONFIRMATION_DEPTH) - }) - }) + ) + (begin + (asserts! (is-none (get pending-2 pipe)) ERR_ALREADY_PENDING) + (merge pipe { pending-2: (some { + amount: amount, + burn-height: (+ burn-block-height CONFIRMATION_DEPTH), + }) } ) ) - ) + )) ) ) @@ -1085,17 +1097,23 @@ (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) ) ) -;;; 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 )) @@ -1110,18 +1128,16 @@ (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)) + (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) @@ -1140,41 +1156,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 +1193,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 +1237,28 @@ ) ;;; 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) + (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)) ) @@ -1246,45 +1267,62 @@ ;;; 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. -;;; 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)) - ;; 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 + (let ((recovered (unwrap! + (principal-of? (unwrap! (secp256k1-recover? hash signature) ERR_INVALID_SIGNATURE)) + ERR_INVALID_SIGNATURE + ))) + (asserts! + (or + (is-eq recovered signer) + ;; 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 ) + (ok true) ) ) +;;; 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 @@ -1292,24 +1330,31 @@ (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) ) ) -;;; 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, - 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,29 +1362,89 @@ 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)) ) - (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 @@ -1352,44 +1457,127 @@ (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)) - (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))) - ERR_INVALID_BALANCES) + (<= balance-1 + (+ (get confirmed-1 state) (get pending-1 state) + (get confirmed-2 state) + )) + ERR_INVALID_BALANCES + ) (asserts! - (<= + (<= balance-2 + (+ (get confirmed-2 state) (get pending-2 state) + (get confirmed-1 state) + )) + ERR_INVALID_BALANCES + ) + (ok true) + ) +) + +;;; 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)) + (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)) + (actor-balance (if (is-eq actor principal-1) + balance-1 balance-2 - (+ - (get confirmed pipe-balances-2) - (get pending pipe-balances-2) - (get confirmed pipe-balances-1))) - ERR_INVALID_BALANCES) + )) + (actor-pending (if (is-eq actor principal-1) + (get pending-1 state) + (get pending-2 state) + )) + ) + (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 (+ (get total state) 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! (>= (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 (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) + ) + ) + ) + ) +) - (ok true) +;;; 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 + ) ) ) @@ -1398,14 +1586,42 @@ ;;; 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, + } + ) +) + +;;; 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/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) ) ) 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..c51cc60 --- /dev/null +++ b/demo/x402-browser/styles.css @@ -0,0 +1,193 @@ +: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; + grid-template-columns: minmax(0, 1fr); + 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; + min-width: 0; +} + +.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; + max-width: 100%; + min-width: 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + 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 new file mode 100644 index 0000000..43615e0 --- /dev/null +++ b/deployments/default.devnet-plan.yaml @@ -0,0 +1,107 @@ +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: requirement-publish + contract-id: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry + remap-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + remap-principals: + SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 112090 + path: .cache/requirements/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry.clar + clarity-version: 3 + - transaction-type: requirement-publish + contract-id: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token + remap-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + remap-principals: + SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 47590 + path: .cache/requirements/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token.clar + clarity-version: 3 + - transaction-type: requirement-publish + contract-id: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-deposit + remap-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + remap-principals: + SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 41510 + path: .cache/requirements/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-deposit.clar + clarity-version: 3 + epoch: '3.0' + - id: 2 + transactions: + - transaction-type: requirement-publish + contract-id: SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0 + remap-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + remap-principals: + SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 43920 + path: .cache/requirements/SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.clar + clarity-version: 4 + - 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: 187470 + path: contracts/reservoir.clar + anchor-block-only: true + clarity-version: 4 + - transaction-type: contract-publish + contract-name: stackflow + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 519850 + path: contracts/stackflow.clar + anchor-block-only: true + clarity-version: 4 + - transaction-type: contract-publish + contract-name: stackflow-sbtc + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + cost: 519850 + 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 + - id: 3 + transactions: + - transaction-type: contract-call + contract-id: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + method: init + parameters: + - none + cost: 10000 + - transaction-type: contract-call + contract-id: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow-sbtc + expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + method: init + parameters: + - "(some 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.sbtc-token)" + cost: 10000 + epoch: '3.3' diff --git a/deployments/default.simnet-plan.yaml b/deployments/default.simnet-plan.yaml index e9b6c61..1e024ca 100644 --- a/deployments/default.simnet-plan.yaml +++ b/deployments/default.simnet-plan.yaml @@ -1,98 +1,121 @@ ---- 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: - - costs - - pox - - pox-2 - - pox-3 - - pox-4 - - lockup - - costs-2 - - costs-3 - - cost-voting - - bns + - 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.0" - - 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 - - emulated-contract-publish: - contract-name: test-token - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/test-token.clar - clarity-version: 3 - epoch: "3.0" - - id: 2 - transactions: - - emulated-contract-publish: - contract-name: stackflow-token - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow-token.clar - clarity-version: 3 - - emulated-contract-publish: - contract-name: stackflow - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/stackflow.clar - clarity-version: 3 - - emulated-contract-publish: - contract-name: reservoir - emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM - path: contracts/reservoir.clar - clarity-version: 3 - epoch: "3.1" + - 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: sbtc-registry + emulated-sender: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4 + path: ./.cache/requirements/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry.clar + clarity-version: 3 + - transaction-type: emulated-contract-publish + contract-name: sbtc-token + emulated-sender: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4 + path: ./.cache/requirements/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token.clar + clarity-version: 3 + - transaction-type: emulated-contract-publish + contract-name: sbtc-deposit + emulated-sender: SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4 + path: ./.cache/requirements/SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-deposit.clar + clarity-version: 3 + epoch: '3.0' + - id: 2 + transactions: + - transaction-type: emulated-contract-publish + contract-name: stackflow-token-0-6-0 + emulated-sender: SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV + path: ./.cache/requirements/SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-6-0.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: stackflow-sbtc + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/stackflow.clar + clarity-version: 4 + - 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: test-token + emulated-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM + path: contracts/test-token.clar + clarity-version: 4 + epoch: '3.3' 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/AGENT-PIPE-TEST-LOG.md b/docs/AGENT-PIPE-TEST-LOG.md new file mode 100644 index 0000000..f1ee807 --- /dev/null +++ b/docs/AGENT-PIPE-TEST-LOG.md @@ -0,0 +1,24 @@ +# Agent Pipe Test Log (Warm Idris) + +Purpose: capture real-world StackFlow pipe test outcomes so onboarding for other agents is based on observed behavior, not assumptions. + +## Per-test template + +- Timestamp (UTC): +- Counterparty: +- Pipe identifier / contract: +- Scenario: +- Preconditions: +- Action executed: +- Expected result: +- Observed result: +- Artifacts (txid, signatures, nonce, logs): +- Pass/Fail: +- Root cause (if fail): +- Fix / mitigation: +- Process improvement for future agents: + +--- + +## Run log + diff --git a/docs/app.js b/docs/app.js new file mode 100644 index 0000000..2164a72 --- /dev/null +++ b/docs/app.js @@ -0,0 +1,1245 @@ +import { + connect, + disconnect, + isConnected, + request, +} from "https://esm.sh/@stacks/connect?bundle&target=es2020"; +import { createNetwork } from "https://esm.sh/@stacks/network@7.2.0?bundle&target=es2020"; +import { + Cl, + Pc, + cvToJSON, + fetchCallReadOnlyFunction, + getAddressFromPublicKey, + principalCV, + publicKeyFromSignatureRsv, + 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 BNSV2_API_BASE = "https://api.bnsv2.com"; +const CONTRACT_PRESETS_BY_NETWORK = { + devnet: [ + { + key: "stx-devnet", + label: "STX (devnet default)", + contractId: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow", + tokenContract: "", + }, + { + key: "sbtc-devnet", + label: "sBTC (devnet default)", + contractId: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow-sbtc", + tokenContract: "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.test-token", + }, + ], + testnet: [ + { + key: "stx-testnet", + label: "STX (testnet default)", + contractId: "ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0", + tokenContract: "", + }, + { + key: "sbtc-testnet", + label: "sBTC (testnet default)", + contractId: "ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-sbtc-0-6-0", + tokenContract: "", + }, + ], + mainnet: [ + { + key: "stx-mainnet", + label: "STX (mainnet default)", + contractId: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-0-6-0", + tokenContract: "", + }, + { + key: "sbtc-mainnet", + label: "sBTC (mainnet default)", + 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"), + openAmount: document.getElementById("open-amount"), + openNonce: document.getElementById("open-nonce"), + // Pipe State + pipeNonce: document.getElementById("pipe-nonce"), + pipeMyBalance: document.getElementById("pipe-my-balance"), + pipeTheirBalance: document.getElementById("pipe-their-balance"), + // Proposed Action + actionType: document.getElementById("action-type"), + actionAmount: document.getElementById("action-amount"), + actionAmountRow: document.getElementById("action-amount-row"), + resultNonce: document.getElementById("result-nonce"), + resultActionCode: document.getElementById("result-action-code"), + resultMyBalance: document.getElementById("result-my-balance"), + resultTheirBalance: document.getElementById("result-their-balance"), + // Shared transfer fields + transferActor: document.getElementById("transfer-actor"), + actorCustomRow: document.getElementById("actor-custom-row"), + transferActorCustom: document.getElementById("transfer-actor-custom"), + transferSecret: document.getElementById("transfer-secret"), + transferValidAfter: document.getElementById("transfer-valid-after"), + // Sign & Validate + mySignature: document.getElementById("my-signature"), + validateSignature: document.getElementById("validate-signature"), + validateSigBtn: document.getElementById("validate-sig-btn"), + useMySignatureBtn: document.getElementById("use-my-sig-btn"), + validationResult: document.getElementById("validation-result"), + validationIcon: document.getElementById("validation-icon"), + validationText: document.getElementById("validation-text"), + // Common + walletStatus: document.getElementById("wallet-status"), + connectWallet: document.getElementById("connect-wallet"), + disconnectWallet: document.getElementById("disconnect-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, + nameCache: new Map(), +}; + +function normalizedText(value) { + return String(value ?? "").trim(); +} + +function isStacksAddress(value) { + return /^S[PMTN][A-Z0-9]{38,42}$/i.test(normalizedText(value)); +} + +function isAddressOnNetwork(address, network = readNetwork()) { + const text = normalizedText(address).toUpperCase(); + if (!text) { + return false; + } + if (network === "mainnet") { + return /^S[PM]/.test(text); + } + return /^S[TN]/.test(text); +} + +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); +} + +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 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 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; + } + + 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() { + 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, network = readNetwork()) { + const seen = new Set(); + const found = []; + const crawl = (value) => { + if (value == null) return; + if (typeof value === "string" && isStacksAddress(value)) { + found.push(value); + return; + } + if (typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + + if (Array.isArray(value)) { + for (const entry of value) { + crawl(entry); + } + return; + } + + if (typeof value.address === "string" && isStacksAddress(value.address)) { + found.push(value.address); + } + for (const nested of Object.values(value)) { + crawl(nested); + } + }; + crawl(response); + return found.find((address) => isAddressOnNetwork(address, network)) || found[0] || null; +} + +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, readNetwork()); + if (!address) { + throw new Error("No Stacks address was returned by the wallet"); + } + state.connectedAddress = address; + elements.forPrincipal.value = elements.forPrincipal.value || address; + setWalletStatus(`Connected: ${address}`); + updateActorOptions(); + return address; + } + + return null; +} + +function updateNetworkDefaults() { + const network = readNetwork(); + if (!normalizedText(elements.stacksApiUrl.value)) { + elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[network]; + } +} + +function getPresetsForNetwork(network = readNetwork()) { + return CONTRACT_PRESETS_BY_NETWORK[network] || []; +} + +function findPresetByKey(presetKey, network = readNetwork()) { + return getPresetsForNetwork(network).find((preset) => preset.key === presetKey) || null; +} + +function renderPresetOptions(network = readNetwork()) { + const presets = getPresetsForNetwork(network); + const selected = normalizedText(elements.contractPreset.value) || "custom"; + const options = [ + ...presets.map((preset) => ``), + '', + ]; + elements.contractPreset.innerHTML = options.join(""); + + if (presets.some((preset) => preset.key === selected) || selected === "custom") { + elements.contractPreset.value = selected; + } else { + elements.contractPreset.value = "custom"; + } +} + +function getPresetKeyByValues(contractId, tokenContract, network = readNetwork()) { + const contractText = normalizedText(contractId); + const tokenText = normalizedText(tokenContract); + for (const preset of getPresetsForNetwork(network)) { + if ( + normalizedText(preset.contractId) === contractText && + normalizedText(preset.tokenContract) === tokenText + ) { + return preset.key; + } + } + return "custom"; +} + +function applyContractPreset(presetKey, { log = true } = {}) { + const preset = findPresetByKey(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 }); + 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 handleDisconnectWallet() { + const previousAddress = state.connectedAddress; + try { + await disconnect(); + } catch { + // Some providers may not implement explicit disconnect cleanly; still clear local state. + } + + state.connectedAddress = null; + state.lastSignature = null; + if (previousAddress && normalizedText(elements.forPrincipal.value) === previousAddress) { + elements.forPrincipal.value = ""; + } + if (previousAddress && normalizedText(elements.transferActor.value) === previousAddress) { + elements.transferActor.value = ""; + } + setWalletStatus("Wallet not connected."); + appendLog(previousAddress ? `Wallet disconnected: ${previousAddress}` : "Wallet disconnected."); +} + +function getReadOnlySenderCandidates(sender, contractAddress, network = readNetwork()) { + const candidates = []; + const senderText = normalizedText(sender); + const contractAddressText = normalizedText(contractAddress); + + if (senderText && isAddressOnNetwork(senderText, network)) { + candidates.push(senderText); + } + if ( + contractAddressText && + isAddressOnNetwork(contractAddressText, network) && + !candidates.includes(contractAddressText) + ) { + candidates.push(contractAddressText); + } + if (senderText && !candidates.includes(senderText)) { + candidates.push(senderText); + } + if (contractAddressText && !candidates.includes(contractAddressText)) { + candidates.push(contractAddressText); + } + return candidates; +} + +async function fetchReadOnly(functionName, functionArgs, sender) { + const { contractAddress, contractName } = parseContractId(); + const network = createNetwork({ + network: readNetwork(), + client: { baseUrl: getStacksApiBase() }, + }); + const senders = getReadOnlySenderCandidates(sender, contractAddress); + + let lastError = null; + for (const senderCandidate of senders) { + try { + const result = await fetchCallReadOnlyFunction({ + network, + senderAddress: senderCandidate, + contractAddress, + contractName, + functionName, + functionArgs, + }); + if (senderCandidate !== sender) { + appendLog(`Read-only ${functionName} used fallback sender=${senderCandidate}.`); + } + return result; + } catch (error) { + lastError = error; + } + } + const message = + lastError instanceof Error ? lastError.message : "Read-only call failed for all sender candidates"; + throw new Error(message); +} + +async function handleGetPipe() { + try { + const withPrincipal = await resolvePrincipalInput( + "Counterparty", + elements.counterparty.value, + ); + const forPrincipal = await resolvePrincipalInput( + "For Principal", + elements.forPrincipal.value || state.connectedAddress, + ); + const { cv: tokenCV } = parseOptionalTokenCV(); + + const resultCv = await fetchReadOnly( + "get-pipe", + [tokenCV, Cl.principal(withPrincipal)], + forPrincipal, + ); + const resultHex = cvHex(resultCv); + const decoded = unwrapClarityJson(cvToJSON(resultCv)); + setOutput({ + call: "get-pipe", + forPrincipal, + withPrincipal, + resultHex, + decoded, + }); + + // Populate Pipe State fields when we get valid data + if (decoded && decoded.nonce !== undefined) { + elements.pipeNonce.value = decoded.nonce; + const pair = canonicalPrincipals(forPrincipal, withPrincipal); + const iAmP1 = pair.principal1 === forPrincipal; + elements.pipeMyBalance.value = iAmP1 ? (decoded["balance-1"] ?? "0") : (decoded["balance-2"] ?? "0"); + elements.pipeTheirBalance.value = iAmP1 ? (decoded["balance-2"] ?? "0") : (decoded["balance-1"] ?? "0"); + updatePreview(); + appendLog("Fetched pipe state and populated fields."); + } else { + 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 = 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; + 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 = await resolvePrincipalInput( + "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 computeAutoResult() { + const actionType = normalizedText(elements.actionType.value); + const pipeNonce = parseUintInput("Nonce", elements.pipeNonce.value); + const pipeMyBalance = parseUintInput("My Balance", elements.pipeMyBalance.value); + const pipeTheirBalance = parseUintInput("Their Balance", elements.pipeTheirBalance.value); + const amount = parseUintInput("Amount", elements.actionAmount.value); + + if (actionType === "transfer-to") { + if (amount > pipeMyBalance) throw new Error("Amount exceeds my balance"); + return { nonce: pipeNonce + 1n, myBalance: pipeMyBalance - amount, theirBalance: pipeTheirBalance + amount, actionCode: 1n }; + } + if (actionType === "transfer-from") { + if (amount > pipeTheirBalance) throw new Error("Amount exceeds their balance"); + return { nonce: pipeNonce + 1n, myBalance: pipeMyBalance + amount, theirBalance: pipeTheirBalance - amount, actionCode: 1n }; + } + if (actionType === "close") { + return { nonce: pipeNonce + 1n, myBalance: pipeMyBalance, theirBalance: pipeTheirBalance, actionCode: 0n }; + } + throw new Error(`Unknown action type: ${actionType}`); +} + +function updatePreview() { + try { + const result = computeAutoResult(); + elements.resultNonce.value = result.nonce.toString(); + elements.resultMyBalance.value = result.myBalance.toString(); + elements.resultTheirBalance.value = result.theirBalance.toString(); + elements.resultActionCode.value = result.actionCode.toString(); + } catch { + // leave fields as-is on error + } +} + +function truncateAddr(addr) { + const t = normalizedText(addr); + if (!t) return ""; + return t.length > 14 ? `${t.slice(0, 8)}…${t.slice(-4)}` : t; +} + +function updateActorOptions() { + const myRaw = normalizedText(elements.forPrincipal.value) || state.connectedAddress || ""; + const themRaw = normalizedText(elements.counterparty.value) || ""; + const opts = elements.transferActor.options; + opts[0].text = myRaw ? `Me — ${truncateAddr(myRaw)}` : "Me"; + opts[1].text = themRaw ? `Them — ${truncateAddr(themRaw)}` : "Them"; +} + +function handleActorChange() { + const isCustom = normalizedText(elements.transferActor.value) === "custom"; + elements.actorCustomRow.classList.toggle("hidden", !isCustom); +} + +function handleActionTypeChange() { + const actionType = normalizedText(elements.actionType.value); + const hasAmount = actionType === "transfer-to" || actionType === "transfer-from"; + elements.actionAmountRow.classList.toggle("hidden", !hasAmount); + // Auto-select actor based on action + if (actionType === "transfer-to") { + elements.transferActor.value = "me"; + } else if (actionType === "transfer-from") { + elements.transferActor.value = "them"; + } else if (actionType === "close") { + elements.transferActor.value = "me"; + } + handleActorChange(); + updatePreview(); +} + +function cvToBytes(cv) { + const result = serializeCV(cv); + // serializeCV returns a hex string in stacks.js v7 + if (typeof result === "string") { + const hex = result.startsWith("0x") ? result.slice(2) : result; + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); + } + return bytes; + } + return result; +} + +async function computeStructuredDataHash(domain, message) { + // SIP-018: sha256("SIP018" || sha256(domain_bytes) || sha256(message_bytes)) + const prefix = new Uint8Array([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); + const [domainHashBuf, messageHashBuf] = await Promise.all([ + crypto.subtle.digest("SHA-256", cvToBytes(domain)), + crypto.subtle.digest("SHA-256", cvToBytes(message)), + ]); + const payload = new Uint8Array(prefix.length + 32 + 32); + payload.set(prefix, 0); + payload.set(new Uint8Array(domainHashBuf), prefix.length); + payload.set(new Uint8Array(messageHashBuf), prefix.length + 32); + return new Uint8Array(await crypto.subtle.digest("SHA-256", payload)); +} + +async function buildTransferContext() { + const network = readNetwork(); + const { contractId } = parseContractId(); + const forPrincipal = await resolvePrincipalInput( + "For Principal", + elements.forPrincipal.value || state.connectedAddress, + ); + const withPrincipal = await resolvePrincipalInput( + "Counterparty", + elements.counterparty.value, + ); + const actorMode = normalizedText(elements.transferActor.value); + const actor = + actorMode === "me" ? forPrincipal + : actorMode === "them" ? withPrincipal + : await resolvePrincipalInput("Custom Actor", elements.transferActorCustom.value); + const myBalance = parseUintInput("My Resulting Balance", elements.resultMyBalance.value); + const theirBalance = parseUintInput("Their Resulting Balance", elements.resultTheirBalance.value); + const nonce = parseUintInput("Resulting Nonce", elements.resultNonce.value); + const action = parseUintInput("Action Code", elements.resultActionCode.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 = await buildTransferContext(); + const response = await request("stx_signStructuredMessage", { + network: readNetwork(), + domain: context.domain, + message: context.message, + }); + const signature = extractSignature(response); + if (!signature) { + throw new Error("Wallet did not return a signature"); + } + state.lastSignature = signature; + elements.mySignature.value = 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, + 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 }); + } +} + +async function handleBuildPayload() { + try { + const context = await 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, + signature: 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 }); + } +} + +function showValidationResult(success, text) { + elements.validationResult.classList.remove("hidden"); + elements.validationIcon.textContent = success ? "✓" : "✗"; + elements.validationIcon.className = `validation-icon ${success ? "ok" : "fail"}`; + elements.validationText.textContent = text; +} + +async function handleValidateSignature() { + try { + const sigInput = normalizedText(elements.validateSignature.value); + if (!sigInput) throw new Error("No signature to validate"); + + const sig = sigInput.startsWith("0x") ? sigInput.slice(2) : sigInput; + if (!/^[0-9a-fA-F]{130}$/.test(sig)) { + throw new Error("Signature must be 65 bytes (130 hex chars)"); + } + + const context = await buildTransferContext(); + const hashBytes = await computeStructuredDataHash(context.domain, context.message); + const hashHex = toHex(hashBytes); + + const network = readNetwork(); + const forAddr = context.forPrincipal.split(".")[0]; + const withAddr = context.withPrincipal.split(".")[0]; + + // Try as-is (RSV), then with recovery byte moved from front to back (VRS→RSV) + const candidates = [sig, sig.slice(2) + sig.slice(0, 2)]; + let recoveredAddress = null; + let isParticipant = false; + let label = ""; + + for (const candidate of candidates) { + try { + const pubKey = publicKeyFromSignatureRsv(hashHex, candidate); + const addr = getAddressFromPublicKey(pubKey, network); + if (recoveredAddress === null) recoveredAddress = addr; + if (addr === forAddr) { + recoveredAddress = addr; + label = `Signed by ME — ${addr}`; + isParticipant = true; + break; + } + if (addr === withAddr) { + recoveredAddress = addr; + label = `Signed by COUNTERPARTY — ${addr}`; + isParticipant = true; + break; + } + } catch { + // try next format + } + } + + if (!recoveredAddress) throw new Error("Could not recover signer from signature"); + if (!isParticipant) label = `Unknown signer — ${recoveredAddress}`; + + showValidationResult(isParticipant, label); + setOutput({ recoveredAddress, isParticipant, label }); + appendLog(`Signature validation: ${label}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + showValidationResult(false, message); + setOutput(`Error: ${message}`); + appendLog(`Validate signature failed: ${message}`, { error: true }); + } +} + +function handleUseMySignature() { + const sig = normalizedText(elements.mySignature.value) || state.lastSignature; + if (!sig) { + appendLog("No signature generated yet — sign with wallet first.", { error: true }); + return; + } + elements.validateSignature.value = sig; +} + +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.disconnectWallet.addEventListener("click", handleDisconnectWallet); + 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.validateSigBtn.addEventListener("click", handleValidateSignature); + elements.useMySignatureBtn.addEventListener("click", handleUseMySignature); + elements.copyOutput.addEventListener("click", handleCopyOutput); + // Pipe State / Action preview live updates + elements.actionType.addEventListener("change", handleActionTypeChange); + elements.transferActor.addEventListener("change", handleActorChange); + for (const id of ["pipe-nonce", "pipe-my-balance", "pipe-their-balance", "action-amount"]) { + document.getElementById(id).addEventListener("input", updatePreview); + } + // Refresh actor option labels when principals change + elements.forPrincipal.addEventListener("input", updateActorOptions); + elements.counterparty.addEventListener("input", updateActorOptions); + elements.network.addEventListener("change", () => { + const previousPresetKey = elements.contractPreset.value; + elements.stacksApiUrl.value = DEFAULT_API_BY_NETWORK[readNetwork()]; + renderPresetOptions(); + if (previousPresetKey !== "custom") { + const presets = getPresetsForNetwork(); + if (presets.length > 0) { + elements.contractPreset.value = presets[0].key; + applyContractPreset(presets[0].key); + } + return; + } + elements.contractPreset.value = getPresetKeyByValues( + elements.contractId.value, + elements.tokenContract.value, + ); + }); + elements.contractPreset.addEventListener("change", () => { + const presetKey = elements.contractPreset.value; + if (presetKey === "custom") { + return; + } + applyContractPreset(presetKey); + }); + 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(); + handleActionTypeChange(); + updateActorOptions(); + updateNetworkDefaults(); + renderPresetOptions(); + if (!normalizedText(elements.contractId.value) && !normalizedText(elements.tokenContract.value)) { + const presets = getPresetsForNetwork(); + if (presets.length > 0) { + elements.contractPreset.value = presets[0].key; + applyContractPreset(presets[0].key, { log: false }); + } else { + elements.contractPreset.value = "custom"; + } + } else { + elements.contractPreset.value = getPresetKeyByValues( + elements.contractId.value, + elements.tokenContract.value, + ); + } + 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..dbab020 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,213 @@ + + + + + + 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

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

Pipe State

+

Current on-chain state of the pipe. "Get Pipe" above will also populate these fields automatically.

+
+ + + +
+
+ +
+

Proposed Action

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

Sign & Validate

+
+

Generate Signature

+
+ + +
+ +
+
+

Validate Signature

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

Output

+
Ready.
+
+ +
+

Log

+
Ready.
+
+
+ + + + diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..9630bb5 --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,284 @@ +: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; + grid-template-columns: minmax(0, 1fr); + 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; + min-width: 0; +} + +.card h2 { + margin: 0 0 0.75rem; + font-size: 1.06rem; +} + +.grid { + display: grid; + gap: 0.65rem; +} + +.grid > * { + min-width: 0; +} + +.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 { + width: 100%; + min-width: 0; + 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; + max-width: 100%; + min-width: 0; + 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; +} + +#log { + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +#wallet-status.error { + color: var(--danger); +} + +.card-hint { + margin: -0.2rem 0 0.65rem; + font-size: 0.82rem; + color: var(--muted); +} + +.hidden { + display: none !important; +} + +.result-fields { + margin-top: 0.65rem; + padding-top: 0.65rem; + border-top: 1px solid var(--line); +} + +.action-preview { + margin-top: 0.8rem; + padding: 0.6rem 0.85rem; + background: #f4f8f4; + border: 1px solid var(--line); + border-radius: 0.62rem; +} + +.preview-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.28rem 1rem; + align-items: baseline; +} + +.preview-label { + font-size: 0.82rem; + color: var(--muted); + white-space: nowrap; +} + +.preview-value { + font-family: "IBM Plex Mono", "Consolas", monospace; + font-size: 0.88rem; + font-weight: 600; + color: var(--ink); +} + +.sig-subsection { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--line); +} + +.sig-subsection:last-of-type { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.sub-heading { + margin: 0 0 0.65rem; + font-size: 0.95rem; + font-weight: 600; + color: var(--ink); +} + +.sig-field { + display: block; + width: 100%; + min-height: 3.5rem; + max-height: 6rem; + resize: vertical; + margin-top: 0.35rem; + word-break: break-all; + white-space: pre-wrap; +} + +.btn-secondary { + background: #566860; +} + +.validation-result { + display: flex; + align-items: center; + gap: 0.6rem; + margin-top: 0.65rem; + padding: 0.55rem 0.8rem; + border-radius: 0.55rem; + background: #f4f8f4; + border: 1px solid var(--line); + font-size: 0.9rem; +} + +.validation-icon { + font-size: 1.1rem; + font-weight: bold; + line-height: 1; +} + +.validation-icon.ok { + color: var(--accent); +} + +.validation-icon.fail { + color: var(--danger); +} + +@media (max-width: 860px) { + .grid.two, + .grid.three { + grid-template-columns: 1fr; + } +} diff --git a/init-stackflow.sh b/init-stackflow.sh new file mode 100755 index 0000000..def0a29 --- /dev/null +++ b/init-stackflow.sh @@ -0,0 +1,15 @@ +#!/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_INIT_MODE="${STACKFLOW_INIT_MODE:-devnet-both}" \ +STACKFLOW_CONTRACT_ID="${STACKFLOW_CONTRACT_ID:-ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow}" \ +STACKFLOW_SBTC_CONTRACT_ID="${STACKFLOW_SBTC_CONTRACT_ID:-ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.stackflow-sbtc}" \ +STACKFLOW_SBTC_TOKEN_CONTRACT_ID="${STACKFLOW_SBTC_TOKEN_CONTRACT_ID:-ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.test-token}" \ +npm run init:stackflow diff --git a/package-lock.json b/package-lock.json index 8b46b01..91a8563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,680 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@hirosystems/clarinet-sdk": "^2.3.2", - "@stacks/transactions": "^6.12.0", + "@aws-sdk/client-kms": "^3.993.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", "typescript": "^5.3.3", - "vite": "^6.2.6", + "vite": "^6.4.2", "vitest": "^3.1.1", - "vitest-environment-clarinet": "^2.0.0" + "vitest-environment-clarinet": "^3.0.2" + }, + "devDependencies": { + "@noble/hashes": "^1.1.5", + "@types/node": "^25.2.3", + "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.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", + "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.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", + "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 +696,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.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 +712,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.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 +728,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.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 +744,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.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 +760,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -115,9 +776,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.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 +792,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -147,9 +808,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -163,9 +824,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -179,9 +840,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.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 +856,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.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 +872,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.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 +888,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.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 +904,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -259,9 +920,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.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 +936,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.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 +952,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.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 +968,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -323,9 +984,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -339,9 +1000,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -354,10 +1015,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.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", + "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 +1048,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.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 +1064,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -403,9 +1080,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.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -418,36 +1095,10 @@ "node": ">=18" } }, - "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==", - "license": "GPL-3.0", - "dependencies": { - "@hirosystems/clarinet-sdk-wasm": "2.15.1", - "@stacks/transactions": "^6.13.0", - "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==", - "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": { @@ -475,9 +1126,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.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" ], @@ -488,9 +1139,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.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" ], @@ -501,9 +1152,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.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -514,9 +1165,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.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" ], @@ -527,9 +1178,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.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -540,9 +1191,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.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -553,9 +1204,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.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" ], @@ -566,9 +1217,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.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" ], @@ -579,9 +1230,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.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" ], @@ -592,9 +1243,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.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" ], @@ -604,10 +1255,10 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "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" ], @@ -617,12 +1268,12 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-loong64-musl": { + "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": [ - "ppc64" + "loong64" ], "license": "MIT", "optional": true, @@ -630,12 +1281,12 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "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": [ - "riscv64" + "ppc64" ], "license": "MIT", "optional": true, @@ -643,12 +1294,12 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "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": [ - "s390x" + "ppc64" ], "license": "MIT", "optional": true, @@ -656,12 +1307,12 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "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": [ - "x64" + "riscv64" ], "license": "MIT", "optional": true, @@ -669,12 +1320,12 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "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": [ - "x64" + "riscv64" ], "license": "MIT", "optional": true, @@ -682,56 +1333,825 @@ "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "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": [ - "arm64" + "s390x" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-x64-gnu": { + "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": [ - "ia32" + "x64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, - "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==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "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" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "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" + ], + "license": "MIT", + "optional": true, + "os": [ + "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/@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": { + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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": { + "@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/@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": { + "@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/@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": { + "@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/@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.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", + "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.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", + "dependencies": { + "@stacks/clarinet-sdk-wasm": "3.14.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.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": "6.16.0", - "resolved": "https://registry.npmjs.org/@stacks/common/-/common-6.16.0.tgz", - "integrity": "sha512-PnzvhrdGRMVZvxTulitlYafSK4l02gPCBBoI9QEoTqgSnv62oaOXhYAUUkTMFKxdHW1seVEwZsrahuXiZPIAwg==", + "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": { - "@types/bn.js": "^5.1.0", - "@types/node": "^18.0.4" + "@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-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": { + "@stencil/core": "^2.17.1" + } + }, + "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", + "@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/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": { + "@stacks/common": "^7.3.1", + "cross-fetch": "^3.1.5" + } + }, + "node_modules/@stacks/network-v6": { + "name": "@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==", @@ -741,7 +2161,61 @@ "cross-fetch": "^3.1.5" } }, + "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==", + "license": "MIT", + "dependencies": { + "@types/bn.js": "^5.1.0", + "@types/node": "^18.0.4" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "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/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.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/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/transactions/-/transactions-6.17.0.tgz", "integrity": "sha512-FUah2BRgV66ApLcEXGNGhwyFTRXqX5Zco3LpiM3essw8PF0NQlHwwdPgtDko5RfrJl3LhGXXe/30nwsfNnB3+g==", @@ -755,38 +2229,103 @@ "lodash.clonedeep": "^4.5.0" } }, + "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==", + "license": "MIT", + "dependencies": { + "@types/bn.js": "^5.1.0", + "@types/node": "^18.0.4" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@stacks/common": "^6.16.0", + "cross-fetch": "^3.1.5" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "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/@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.1.6", - "resolved": "https://registry.npmjs.org/@types/bn.js/-/bn.js-5.1.6.tgz", - "integrity": "sha512-Xh8vSwUeMKeYYrj3cX4lGQgFSF/N03r+tv4AiLl1SucqV+uTQpxRcnM8AkXKHwYP9ZPXOYXRr2KPXpVlIvqh9w==", + "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", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "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.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "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/@types/node": { - "version": "18.19.75", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.75.tgz", - "integrity": "sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==", + "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": "~5.26.4" + "undici-types": "~7.18.0" } }, "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.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "license": "MIT", "dependencies": { - "@vitest/spy": "3.1.1", - "@vitest/utils": "3.1.1", + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, @@ -795,12 +2334,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.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.1.1", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -809,7 +2348,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": { @@ -821,9 +2360,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.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" @@ -833,25 +2372,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.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "license": "MIT", "dependencies": { - "@vitest/utils": "3.1.1", - "pathe": "^2.0.3" + "@vitest/utils": "3.2.4", + "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.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", + "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" }, @@ -860,25 +2400,25 @@ } }, "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.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "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.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.1.1", - "loupe": "^3.1.3", + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" }, "funding": { @@ -886,12 +2426,27 @@ } }, "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": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/anymatch": { @@ -916,10 +2471,39 @@ "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.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/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": { @@ -934,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", @@ -946,6 +2536,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", @@ -978,9 +2577,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", @@ -990,13 +2589,13 @@ "pathval": "^2.0.0" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "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" @@ -1082,15 +2681,6 @@ "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", @@ -1166,17 +2756,17 @@ } }, "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": { @@ -1204,9 +2794,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.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1238,22 +2828,34 @@ "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": "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": { - "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.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -1263,31 +2865,32 @@ "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.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": { @@ -1309,14 +2912,49 @@ } }, "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" } }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" + }, + "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", @@ -1364,6 +3002,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "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" + }, + "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", @@ -1398,12 +3048,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": { @@ -1427,6 +3077,23 @@ "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/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", @@ -1455,6 +3122,12 @@ "node": ">=6" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -1474,18 +3147,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": { @@ -1541,19 +3214,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==", @@ -1568,6 +3229,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", @@ -1586,6 +3259,21 @@ "node": ">=4" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1593,9 +3281,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" @@ -1608,9 +3296,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -1620,9 +3308,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.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -1639,7 +3327,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1687,13 +3375,21 @@ "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.34.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", - "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", + "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.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -1703,28 +3399,63 @@ "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.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" } }, + "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", @@ -1759,37 +3490,67 @@ "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.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" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "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", @@ -1802,10 +3563,55 @@ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "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" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "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.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" @@ -1821,9 +3627,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.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" @@ -1847,10 +3653,16 @@ "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.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "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", @@ -1861,20 +3673,32 @@ } }, "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==", + "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/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.2.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", - "integrity": "sha512-9xpjNl3kR4rVDZgPNdTL0/c6ao4km69a/2ihNQbcANz8RuCOK3hQBmLSJf3bRKVQjVMda+YvizNE8AwvogcPbw==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "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" @@ -1938,16 +3762,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.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", - "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" @@ -1959,31 +3783,63 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "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" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "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==", - "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", + "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.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.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.1", "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.4", "why-is-node-running": "^2.3.0" }, "bin": { @@ -1999,8 +3855,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.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -2029,13 +3885,25 @@ } }, "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.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/webidl-conversions": { @@ -2077,55 +3945,22 @@ } }, "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" } }, - "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", @@ -2136,30 +3971,38 @@ } }, "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" + } + }, + "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 8dba025..001c1d8 100644 --- a/package.json +++ b/package.json @@ -5,19 +5,40 @@ "type": "module", "private": true, "scripts": { - "test": "vitest run", + "deploy:testnet": "node scripts/deploy.js", + "init:stackflow": "node scripts/init-stackflow.js", + "build:ui": "node scripts/build-ui.js", + "test": "npm run test:clarity && npm run test:node", + "test:clarity": "vitest run tests/stackflow.test.ts tests/reservoir.test.ts", + "test:node": "vitest run -c vitest.node.config.js tests/stackflow-agent.test.ts tests/x402-client.test.ts tests/counterparty-service.test.ts tests/forwarding-service.test.ts tests/stackflow-node-config.test.ts tests/stackflow-node-dispute.test.ts tests/stackflow-node-state.test.ts tests/stackflow-node-observer.test.ts tests/stackflow-node-http.integration.test.ts", + "test:node-utils": "vitest run -c vitest.node.config.js tests/x402-client.test.ts tests/stackflow-agent.test.ts tests/stackflow-aibtc-adapter.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\"" + "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", + "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", "dependencies": { - "@hirosystems/clarinet-sdk": "^2.3.2", - "@stacks/transactions": "^6.12.0", + "@aws-sdk/client-kms": "^3.993.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", "typescript": "^5.3.3", - "vite": "^6.2.6", + "vite": "^6.4.2", "vitest": "^3.1.1", - "vitest-environment-clarinet": "^2.0.0" + "vitest-environment-clarinet": "^3.0.2" + }, + "devDependencies": { + "@noble/hashes": "^1.1.5", + "@types/node": "^25.2.3", + "esbuild": "^0.25.12" } } diff --git a/packages/stackflow-agent/README.md b/packages/stackflow-agent/README.md new file mode 100644 index 0000000..54b8ea0 --- /dev/null +++ b/packages/stackflow-agent/README.md @@ -0,0 +1,143 @@ +# @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 + +## Quick workflow (setup pipe + send + receive) + +1. Track pipe locally: + +```js +const tracked = agent.trackPipe({ + contractId: "ST...stackflow-0-6-0", + pipeKey: { "principal-1": "SP...ME", "principal-2": "SP...THEM", token: null }, + localPrincipal: "SP...ME", + counterpartyPrincipal: "SP...THEM", +}); +``` + +2. Open/fund the pipe on-chain: + +```js +await agent.openPipe({ + contractId: tracked.contractId, + token: null, + amount: "1000", + counterpartyPrincipal: tracked.counterpartyPrincipal, + nonce: "0", +}); +``` + +3. Build an outgoing state update to send to counterparty: + +```js +const outgoing = agent.buildOutgoingTransfer({ + pipeId: tracked.pipeId, + amount: "25", + // actor defaults to tracked.localPrincipal +}); +``` + +4. Validate + accept incoming counterparty update: + +```js +const result = await agent.acceptIncomingTransfer({ + pipeId: tracked.pipeId, + payload: { + ...outgoing, + actor: tracked.counterpartyPrincipal, + theirSignature: "0x...", + }, +}); +``` + +5. Persisted local latest state is now available via `getPipeLatestState(...)`. + +## 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. Watcher retries are idempotent for already-disputed closures (same closure txid is skipped on later polls). +5. Read-only polling isolates per-pipe failures (`getPipeState` errors on one pipe do not stop others). +6. Event scan mode intentionally holds the cursor when any dispute submission errors occur, so failed disputes are retried on next run. +7. Event scan mode now reports `listErrors` (event source/indexer failures) and keeps the watcher cursor unchanged on those failures. +8. Invalid closure event payloads are skipped and counted in `invalidEvents` so one malformed record does not abort a full scan. +9. `buildOutgoingTransfer(...)` defaults `actor` to the tracked local principal and rejects mismatched actor values. +10. Incoming transfer validation enforces tracked contract/pipe/principals/token consistency; mismatched `pipeId`, `pipeKey`, `actor`, or token payloads are rejected. +11. Incoming transfer validation also enforces sequential nonces and balance invariants against the latest stored local state (same total balance, and counterparty-actor updates must not reduce local balance). +12. 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..5c5461f --- /dev/null +++ b/packages/stackflow-agent/src/agent-service.js @@ -0,0 +1,573 @@ +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 = null, + 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; + + const normalizedActor = + actor == null || String(actor).trim() === "" + ? tracked.localPrincipal + : assertNonEmptyString(actor, "actor"); + if (normalizedActor !== tracked.localPrincipal) { + throw new Error("actor must match tracked local principal"); + } + + 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: normalizedActor, + 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", + }; + } + + if (data.pipeId != null && String(data.pipeId).trim() !== tracked.pipeId) { + return { + valid: false, + reason: "pipe-id-mismatch", + }; + } + + if (data.pipeKey != null) { + if (!data.pipeKey || typeof data.pipeKey !== "object" || Array.isArray(data.pipeKey)) { + return { + valid: false, + reason: "pipe-key-invalid", + }; + } + let incomingPipeId; + try { + incomingPipeId = buildPipeId({ + contractId, + pipeKey: data.pipeKey, + }); + } catch { + return { + valid: false, + reason: "pipe-key-invalid", + }; + } + if (incomingPipeId !== tracked.pipeId) { + return { + valid: false, + reason: "pipe-key-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 trackedToken = + tracked.token == null ? null : String(tracked.token).trim(); + const payloadToken = + data.token == null ? trackedToken : String(data.token).trim(); + if (payloadToken !== trackedToken) { + return { + valid: false, + reason: "token-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", + }; + } + if (actor !== tracked.counterpartyPrincipal) { + return { + valid: false, + reason: "actor-mismatch", + }; + } + 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, + }; + } + if (incomingNonce !== existingNonce + 1n) { + return { + valid: false, + reason: "nonce-not-sequential", + existingNonce: latest.nonce, + }; + } + + const existingMyBalance = parseUnsignedBigInt( + latest.myBalance, + "existing myBalance", + ); + const existingTheirBalance = parseUnsignedBigInt( + latest.theirBalance, + "existing theirBalance", + ); + const incomingMyBalance = parseUnsignedBigInt(myBalance, "incoming myBalance"); + const incomingTheirBalance = parseUnsignedBigInt( + theirBalance, + "incoming theirBalance", + ); + + if ( + incomingMyBalance + incomingTheirBalance !== + existingMyBalance + existingTheirBalance + ) { + return { + valid: false, + reason: "balance-sum-mismatch", + }; + } + + if ( + incomingMyBalance < existingMyBalance || + incomingTheirBalance > existingTheirBalance + ) { + return { + valid: false, + reason: "balance-direction-invalid", + }; + } + } + + 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: trackedToken, + 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; + + // Build the flat Clarity-typed message matching the on-chain SIP-018 domain. + // balance-1 always corresponds to principal-1 in the pipe key (canonical ordering), + // regardless of which side the local agent is on. + const pipeKey = state.pipeKey; + const localIsPrincipal1 = pipeKey["principal-1"] === state.forPrincipal; + const balance1 = localIsPrincipal1 ? state.myBalance : state.theirBalance; + const balance2 = localIsPrincipal1 ? state.theirBalance : state.myBalance; + + const message = { + "principal-1": { type: "principal", value: pipeKey["principal-1"] }, + "principal-2": { type: "principal", value: pipeKey["principal-2"] }, + token: + pipeKey.token == null + ? { type: "none" } + : { type: "some", value: { type: "principal", value: String(pipeKey.token) } }, + "balance-1": { type: "uint", value: Number(balance1) }, + "balance-2": { type: "uint", value: Number(balance2) }, + nonce: { type: "uint", value: Number(state.nonce) }, + action: { type: "uint", value: Number(state.action) }, + actor: { type: "principal", value: state.actor }, + "hashed-secret": + state.secret == null + ? { type: "none" } + : { type: "some", value: { type: "buff", value: state.secret } }, + "valid-after": + state.validAfter == null + ? { type: "none" } + : { type: "some", value: { type: "uint", value: Number(state.validAfter) } }, + }; + + const mySignature = await this.signTransferMessage({ + contractId: state.contractId, + message, + 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..37ae0f5 --- /dev/null +++ b/packages/stackflow-agent/src/aibtc-adapter.js @@ -0,0 +1,402 @@ +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; +} + +function shouldRetrySip018WithDomain(error) { + const message = String(error instanceof Error ? error.message : error).toLowerCase(); + return ( + message.includes("domain") && + (message.includes("required") || message.includes("missing") || message.includes("must")) + ); +} + +function shouldRetrySip018Legacy(error) { + const message = String(error instanceof Error ? error.message : error).toLowerCase(); + return ( + message.includes("domain") || + message.includes("contract") || + message.includes("input validation") || + message.includes("invalid arguments") + ); +} + +function deriveSip018Domain(contract) { + const contractText = String(contract ?? "").trim(); + if (!contractText) { + return { name: "stackflow", version: "1.0.0" }; + } + + // The Clarity contract defines its domain as: + // { name: (to-ascii? current-contract), version: "0.6.0", chain-id: chain-id } + // `current-contract` serializes as the full principal: "SP....contract-name". + // Use the full contractId as the domain name to match on-chain verification. + const [, contractName = contractText] = contractText.split("."); + const versionMatch = contractName.match(/(\d+)-(\d+)-(\d+)$/); + const version = versionMatch + ? `${versionMatch[1]}.${versionMatch[2]}.${versionMatch[3]}` + : "1.0.0"; + + return { name: contractText, version }; +} + +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 domainArgs = { + message, + domain: deriveSip018Domain(contract), + wallet_password: walletPassword ?? undefined, + }; + + const legacyArgs = { + contract, + message, + wallet_password: walletPassword ?? undefined, + }; + + let result; + try { + result = normalizeToolResult( + await this.invokeTool("sip018_sign", domainArgs), + "sip018_sign", + ); + } catch (error) { + if (!shouldRetrySip018Legacy(error)) { + throw error; + } + result = normalizeToolResult( + await this.invokeTool("sip018_sign", legacyArgs), + "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..620f888 --- /dev/null +++ b/packages/stackflow-agent/src/db.js @@ -0,0 +1,428 @@ +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.getClosureStmt = this.db.prepare(` + SELECT * FROM closures 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"), + ); + } + + getClosure(txid) { + this.assertOpen(); + const row = this.getClosureStmt.get(assertNonEmptyString(txid, "txid")); + if (!row) { + return null; + } + return { + txid: row.txid, + contractId: row.contract_id, + pipeId: row.pipe_id, + pipeKey: JSON.parse(row.pipe_key_json), + eventName: row.event_name, + nonce: row.nonce, + closer: row.closer, + blockHeight: row.block_height, + expiresAt: row.expires_at, + closureMyBalance: row.closure_my_balance, + disputed: row.disputed === 1, + disputeTxid: row.dispute_txid, + createdAt: row.created_at, + }; + } + + 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..50d2d64 --- /dev/null +++ b/packages/stackflow-agent/src/watcher.js @@ -0,0 +1,417 @@ +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; + } + + reportError(error, context = null) { + if (!error) { + return; + } + if (this.onError) { + if (context && error instanceof Error && !error.context) { + error.context = context; + } + this.onError(error); + return; + } + console.error( + `[stackflow-agent] watcher error${ + context ? ` (${context})` : "" + }: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + 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, + skippedAlreadyDisputed: 0, + fetchErrors: 0, + disputeErrors: 0, + }; + } + + let closuresFound = 0; + let disputesSubmitted = 0; + let skippedAlreadyDisputed = 0; + let fetchErrors = 0; + let disputeErrors = 0; + let pipesScanned = 0; + for (const trackedPipe of trackedPipes) { + pipesScanned += 1; + let pipeState; + try { + pipeState = await this.getPipeState({ + contractId: trackedPipe.contractId, + token: trackedPipe.token ?? null, + pipeKey: trackedPipe.pipeKey, + forPrincipal: trackedPipe.localPrincipal, + withPrincipal: trackedPipe.counterpartyPrincipal, + pipeId: trackedPipe.pipeId, + }); + } catch (error) { + fetchErrors += 1; + this.reportError( + error, + `getPipeState:${trackedPipe.contractId}:${trackedPipe.pipeId}`, + ); + continue; + } + const rawClosure = toClosureFromPipeState({ + trackedPipe, + pipeState, + }); + if (!rawClosure) { + continue; + } + let closure; + try { + closure = normalizeClosureEvent(rawClosure); + } catch { + continue; + } + + closuresFound += 1; + const existingClosure = this.agentService.stateStore.getClosure(closure.txid); + this.agentService.stateStore.recordClosure(closure); + if (existingClosure?.disputed) { + skippedAlreadyDisputed += 1; + continue; + } + + let disputeResult; + try { + disputeResult = await this.agentService.disputeClosure({ + closureEvent: closure, + walletPassword: this.walletPassword, + }); + } catch (error) { + disputeErrors += 1; + this.reportError(error, `disputeClosure:${closure.txid}`); + continue; + } + if (disputeResult.submitted) { + disputesSubmitted += 1; + } + } + + return { + ok: true, + mode: "readonly-pipe", + pipesScanned, + closuresFound, + disputesSubmitted, + skippedAlreadyDisputed, + fetchErrors, + disputeErrors, + }; + } 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(); + let events; + try { + events = await this.listClosureEvents({ + fromBlockHeight, + }); + } catch (error) { + this.reportError(error, "listClosureEvents"); + return { + ok: false, + scanned: 0, + invalidEvents: 0, + disputesSubmitted: 0, + skippedAlreadyDisputed: 0, + disputeErrors: 0, + listErrors: 1, + fromBlockHeight, + toBlockHeight: fromBlockHeight, + }; + } + if (!Array.isArray(events) || events.length === 0) { + return { + ok: true, + scanned: 0, + invalidEvents: 0, + disputesSubmitted: 0, + skippedAlreadyDisputed: 0, + disputeErrors: 0, + listErrors: 0, + fromBlockHeight, + toBlockHeight: fromBlockHeight, + }; + } + + let highestBlock = parseUnsignedBigInt(fromBlockHeight, "fromBlockHeight"); + let disputesSubmitted = 0; + let skippedAlreadyDisputed = 0; + let disputeErrors = 0; + let invalidEvents = 0; + let hasDisputeErrors = false; + let scanned = 0; + + for (const rawEvent of events) { + let closure; + try { + closure = normalizeClosureEvent(rawEvent); + } catch { + invalidEvents += 1; + continue; + } + scanned += 1; + const existingClosure = this.agentService.stateStore.getClosure(closure.txid); + this.agentService.stateStore.recordClosure(closure); + if (existingClosure?.disputed) { + skippedAlreadyDisputed += 1; + } else { + let disputeResult; + try { + disputeResult = await this.agentService.disputeClosure({ + closureEvent: closure, + walletPassword: this.walletPassword, + }); + } catch (error) { + disputeErrors += 1; + hasDisputeErrors = true; + this.reportError(error, `disputeClosure:${closure.txid}`); + } + if (disputeResult?.submitted) { + disputesSubmitted += 1; + } + } + + const block = parseUnsignedBigInt(closure.blockHeight, "blockHeight"); + if (block > highestBlock) { + highestBlock = block; + } + } + + const toBlockHeight = hasDisputeErrors + ? fromBlockHeight + : highestBlock.toString(10); + if (!hasDisputeErrors) { + this.agentService.stateStore.setWatcherCursor(toBlockHeight); + } + + return { + ok: true, + scanned, + invalidEvents, + disputesSubmitted, + skippedAlreadyDisputed, + disputeErrors, + listErrors: 0, + fromBlockHeight, + toBlockHeight, + }; + } 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/run-with-devnet.sh b/run-with-devnet.sh new file mode 100755 index 0000000..5d39a12 --- /dev/null +++ b/run-with-devnet.sh @@ -0,0 +1,20 @@ +#!/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_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/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/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/scripts/deploy.js b/scripts/deploy.js new file mode 100644 index 0000000..8fecc75 --- /dev/null +++ b/scripts/deploy.js @@ -0,0 +1,161 @@ +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 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_CONTRACT_NAME, + kind: "stackflow-token", + file: "../contracts/stackflow-token.clar", + }, + { + name: `stackflow-${VERSION_TAG}`, + kind: "stackflow", + file: "../contracts/stackflow.clar", + }, + { + name: `stackflow-sbtc-${VERSION_TAG}`, + kind: "stackflow", + file: "../contracts/stackflow.clar", + }, + { + name: `reservoir-${VERSION_TAG}`, + kind: "reservoir", + file: "../contracts/reservoir.clar", + }, +]; + +const network = STACKS_TESTNET; + +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 = buildContractCode(filePath, kind, senderAddress); + + 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, + contract.kind, + nonce, + senderAddress + ); + nonce += 1; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/init-stackflow.js b/scripts/init-stackflow.js new file mode 100644 index 0000000..4ea05c1 --- /dev/null +++ b/scripts/init-stackflow.js @@ -0,0 +1,193 @@ +import "dotenv/config"; + +import { createNetwork } from "@stacks/network"; +import { + AnchorMode, + PostConditionMode, + broadcastTransaction, + fetchNonce, + getAddressFromPrivateKey, + makeContractCall, + noneCV, + principalCV, + someCV, +} from "@stacks/transactions"; + +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 contract id: ${contractId}`); + } + return { contractAddress: parts[0], contractName: parts[1] }; +} + +function normalizeContractId(label, contractId) { + const normalized = String(contractId || "").trim(); + if (!normalized) { + throw new Error(`${label} is required`); + } + const parsed = parseContractId(normalized); + return `${parsed.contractAddress}.${parsed.contractName}`; +} + +function parseInitMode(value) { + const mode = String(value || "single").trim().toLowerCase(); + if (mode === "single" || mode === "devnet-both") { + return mode; + } + throw new Error("STACKFLOW_INIT_MODE must be one of: single, devnet-both"); +} + +function nextNonce(value) { + return typeof value === "bigint" ? value + 1n : value + 1; +} + +async function submitInitTx({ + network, + senderKey, + nonce, + contractId, + tokenContractId, +}) { + const { contractAddress, contractName } = parseContractId(contractId); + const tokenArg = tokenContractId ? someCV(principalCV(tokenContractId)) : noneCV(); + + console.log( + `[init-stackflow] init contract=${contractId} token=${tokenContractId || "none"} nonce=${nonce.toString()}`, + ); + + const transaction = await makeContractCall({ + network, + senderKey, + contractAddress, + contractName, + functionName: "init", + functionArgs: [tokenArg], + nonce, + anchorMode: AnchorMode.Any, + postConditionMode: PostConditionMode.Allow, + validateWithAbi: false, + }); + + const result = await broadcastTransaction({ + transaction, + network, + }); + + if ("reason" in result) { + throw new Error( + `init broadcast failed contract=${contractId} token=${tokenContractId || "none"} reason=${ + result.reason || "unknown" + }`, + ); + } + + console.log(`[init-stackflow] init broadcast ok contract=${contractId} txid=${result.txid}`); +} + +async function main() { + const stacksNetwork = normalizeNetwork(process.env.STACKS_NETWORK); + const initMode = parseInitMode(process.env.STACKFLOW_INIT_MODE); + 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(); + if (!deployerKeyInput) { + throw new Error( + "DEPLOYER_PRIVATE_KEY is required; refusing to use embedded fixture keys", + ); + } + + const senderKey = normalizePrivateKey(deployerKeyInput); + + const network = createNetwork({ + network: stacksNetwork, + client: { baseUrl: stacksApiUrl }, + }); + + const deployerAddress = getAddressFromPrivateKey(senderKey, network); + console.log(`[init-stackflow] network=${stacksNetwork} api=${stacksApiUrl}`); + console.log(`[init-stackflow] mode=${initMode}`); + console.log(`[init-stackflow] deployer=${deployerAddress}`); + + const initCalls = + initMode === "devnet-both" + ? [ + { + contractId: normalizeContractId( + "STACKFLOW_CONTRACT_ID", + process.env.STACKFLOW_CONTRACT_ID?.trim() || `${deployerAddress}.stackflow`, + ), + tokenContractId: null, + }, + { + contractId: normalizeContractId( + "STACKFLOW_SBTC_CONTRACT_ID", + process.env.STACKFLOW_SBTC_CONTRACT_ID?.trim() || + `${deployerAddress}.stackflow-sbtc`, + ), + tokenContractId: normalizeContractId( + "STACKFLOW_SBTC_TOKEN_CONTRACT_ID", + process.env.STACKFLOW_SBTC_TOKEN_CONTRACT_ID?.trim() || + `${deployerAddress}.test-token`, + ), + }, + ] + : [ + { + contractId: normalizeContractId( + "STACKFLOW_CONTRACT_ID", + process.env.STACKFLOW_CONTRACT_ID?.trim() || `${deployerAddress}.stackflow`, + ), + tokenContractId: process.env.STACKFLOW_TOKEN_CONTRACT_ID?.trim() + ? normalizeContractId( + "STACKFLOW_TOKEN_CONTRACT_ID", + process.env.STACKFLOW_TOKEN_CONTRACT_ID, + ) + : null, + }, + ]; + + let nonce = await fetchNonce({ + address: deployerAddress, + network: stacksNetwork, + client: { baseUrl: stacksApiUrl }, + }); + + for (const call of initCalls) { + await submitInitTx({ + network, + senderKey, + nonce, + contractId: call.contractId, + tokenContractId: call.tokenContractId, + }); + nonce = nextNonce(nonce); + } +} + +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..1c59d52 --- /dev/null +++ b/server/DESIGN.md @@ -0,0 +1,202 @@ +# Stackflow Node Server Design + +## Purpose + +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`). +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/stackflow-node.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/stackflow-node-state.db` +- Config: `STACKFLOW_NODE_DB_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)` +- `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 + +- `pipeId = "||"` +- `stateId = "||"` +- `attemptId = "|"` + +### Data lifecycle + +- 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 + +### 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 /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`. + +Counterparty-mode endpoints apply local policy before signing: + +- Reject if requested nonce is not strictly higher than latest known nonce. +- 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. + +### 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 (`STACKFLOW_NODE_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 + +- `STACKFLOW_NODE_HOST`, `STACKFLOW_NODE_PORT` +- `STACKFLOW_NODE_DB_FILE` +- `STACKFLOW_NODE_MAX_RECENT_EVENTS` +- `STACKFLOW_CONTRACTS` +- `STACKFLOW_NODE_PRINCIPALS` (CSV allowlist, max 100) +- `STACKS_NETWORK` +- `STACKS_API_URL` +- `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` +- `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. +- For HA/multi-instance operation, run a single active dispute writer or add leader coordination. 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/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/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/config.ts b/server/src/config.ts new file mode 100644 index 0000000..91dbee8 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,310 @@ +import path from 'node:path'; + +import { parsePrincipal } from './principal-utils.js'; +import type { + DisputeExecutorMode, + CounterpartySignerMode, + SignatureVerifierMode, + StackflowNodeConfig, +} from './types.js'; +import process from 'node:process'; + +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(), + 'server/data/stackflow-node-state.db', +); + +function parseInteger(value: unknown, fallback: number, key: string): number { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const normalized = String(value).trim(); + if (!/^-?\d+$/.test(normalized)) { + throw new Error(`${key} must be an integer`); + } + + const parsed = Number.parseInt(normalized, 10); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`${key} must be a safe integer`); + } + + return parsed; +} + +function parsePort(value: unknown): number { + const parsed = parseInteger(value, DEFAULT_PORT, 'STACKFLOW_NODE_PORT'); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) { + throw new Error('STACKFLOW_NODE_PORT must be an integer between 1 and 65535'); + } + return parsed; +} + +function parseMaxRecentEvents(value: unknown): number { + return Math.max( + 1, + parseInteger(value, DEFAULT_MAX_RECENT_EVENTS, 'STACKFLOW_NODE_MAX_RECENT_EVENTS'), + ); +} + +function parseCsv(value: unknown): string[] { + if (!value) { + return []; + } + + return String(value) + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function parsePrincipalCsv(value: unknown): string[] { + const principals = parseCsv(value).map((principal) => + parsePrincipal(principal, 'STACKFLOW_NODE_PRINCIPALS'), + ); + + if (principals.length > MAX_WATCHED_PRINCIPALS) { + throw new Error( + `STACKFLOW_NODE_PRINCIPALS exceeds max of ${MAX_WATCHED_PRINCIPALS} entries`, + ); + } + + return [...new Set(principals)]; +} + +function parseBoolean(value: unknown, fallback: boolean, key: string): 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; + } + + throw new Error(`${key} must be a boolean (true/false, 1/0, yes/no, on/off)`); +} + +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 === 'devnet' || + normalized === 'mocknet' + ) { + return normalized; + } + + throw new Error('STACKS_NETWORK must be one of: mainnet, testnet, devnet, mocknet'); +} + +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( + 'STACKFLOW_NODE_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( + 'STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE must be auto, noop, or mock', + ); +} + +function parseCounterpartySignerMode(value: unknown): CounterpartySignerMode { + const normalized = String(value || 'local-key').trim().toLowerCase(); + if (normalized === 'local-key' || normalized === 'kms') { + return normalized; + } + + throw new Error( + '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('STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION must not be empty'); + } + if (!/^[\x20-\x7E]+$/.test(text)) { + throw new Error('STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION must be ASCII'); + } + return text; +} + +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.STACKFLOW_NODE_HOST?.trim() || DEFAULT_HOST, + port: parsePort(env.STACKFLOW_NODE_PORT), + dbFile, + maxRecentEvents: parseMaxRecentEvents(env.STACKFLOW_NODE_MAX_RECENT_EVENTS), + logRawEvents: parseBoolean( + env.STACKFLOW_NODE_LOG_RAW_EVENTS, + false, + 'STACKFLOW_NODE_LOG_RAW_EVENTS', + ), + watchedContracts: parseCsv(env.STACKFLOW_CONTRACTS), + watchedPrincipals: parsePrincipalCsv(env.STACKFLOW_NODE_PRINCIPALS), + stacksNetwork: parseNetwork(env.STACKS_NETWORK), + stacksApiUrl: env.STACKS_API_URL?.trim() || null, + 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.STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION, + ), + signatureVerifierMode: parseSignatureVerifierMode( + env.STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE, + ), + disputeExecutorMode: parseDisputeExecutorMode( + env.STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE, + ), + disputeOnlyBeneficial: parseBoolean( + env.STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL, + false, + 'STACKFLOW_NODE_DISPUTE_ONLY_BENEFICIAL', + ), + peerWriteRateLimitPerMinute: Math.max( + 0, + parseInteger( + env.STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE, + DEFAULT_PEER_WRITE_RATE_LIMIT_PER_MINUTE, + 'STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE', + ), + ), + trustProxy: parseBoolean( + env.STACKFLOW_NODE_TRUST_PROXY, + DEFAULT_TRUST_PROXY, + 'STACKFLOW_NODE_TRUST_PROXY', + ), + observerLocalhostOnly: parseBoolean( + env.STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLY, + DEFAULT_OBSERVER_LOCALHOST_ONLY, + 'STACKFLOW_NODE_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, + 'STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLY', + ), + redactSensitiveReadData: parseBoolean( + env.STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA, + DEFAULT_REDACT_SENSITIVE_READ_DATA, + 'STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATA', + ), + forwardingEnabled: parseBoolean( + env.STACKFLOW_NODE_FORWARDING_ENABLED, + false, + 'STACKFLOW_NODE_FORWARDING_ENABLED', + ), + forwardingMinFee: Math.max( + 0, + parseInteger( + env.STACKFLOW_NODE_FORWARDING_MIN_FEE, + 0, + 'STACKFLOW_NODE_FORWARDING_MIN_FEE', + ), + ).toString(10), + forwardingTimeoutMs: Math.max( + 1_000, + parseInteger( + env.STACKFLOW_NODE_FORWARDING_TIMEOUT_MS, + DEFAULT_FORWARDING_TIMEOUT_MS, + 'STACKFLOW_NODE_FORWARDING_TIMEOUT_MS', + ), + ), + forwardingAllowPrivateDestinations: parseBoolean( + env.STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS, + false, + 'STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONS', + ), + 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, + 'STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS', + ), + ), + forwardingRevealRetryMaxAttempts: Math.max( + 1, + parseInteger( + env.STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS, + DEFAULT_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS, + 'STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS', + ), + ), + }; +} diff --git a/server/src/counterparty-service.ts b/server/src/counterparty-service.ts new file mode 100644 index 0000000..53874ad --- /dev/null +++ b/server/src/counterparty-service.ts @@ -0,0 +1,1312 @@ +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 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; + } + + 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 hashedSecretArg = request.secret + ? someCV(bufferCV(hexToBytes(request.secret))) + : noneCV(); + const validAfterArg = request.validAfter + ? someCV(uintCV(BigInt(request.validAfter))) + : noneCV(); + + return { + pipeKey, + balance1, + balance2, + tokenArg, + secretArg: hashedSecretArg, + 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: 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) { + 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'; + + 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, + 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: (() => { + 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), + }; +} + +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 new file mode 100644 index 0000000..f424a4b --- /dev/null +++ b/server/src/dispute-executor.ts @@ -0,0 +1,143 @@ +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, + StackflowNodeConfig, +} 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 disputeSignerKey: string | null; + + constructor(config: Pick) { + this.network = createNetwork({ + network: config.stacksNetwork, + client: config.stacksApiUrl ? { baseUrl: config.stacksApiUrl } : undefined, + }); + + this.disputeSignerKey = config.disputeSignerKey ? normalizePrivateKey(config.disputeSignerKey) : null; + + this.enabled = Boolean(this.disputeSignerKey); + this.signerAddress = this.disputeSignerKey + ? getAddressFromPrivateKey(this.disputeSignerKey, this.network) + : null; + } + + async submitDispute({ + signatureState, + resolvedSecret, + }: { + signatureState: SignatureStateRecord; + resolvedSecret: string | null; + closure: ClosureRecord; + triggerEvent: StackflowPrintEvent; + }): Promise { + if (!this.disputeSignerKey) { + throw new Error('stackflow-node dispute signer key not configured'); + } + + const contract = parseContractPrincipal(signatureState.contractId); + + const tokenArg = signatureState.token + ? someCV(principalCV(signatureState.token)) + : noneCV(); + + const secretArg = resolvedSecret + ? someCV(bufferCV(hexToBytes(resolvedSecret))) + : noneCV(); + + const validAfterArg = signatureState.validAfter + ? someCV(uintCV(BigInt(signatureState.validAfter))) + : noneCV(); + + const tx = await makeContractCall({ + network: this.network, + senderKey: this.disputeSignerKey, + 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/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 new file mode 100644 index 0000000..ddf443e --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,2689 @@ +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'; +import { + MockDisputeExecutor, + NoopDisputeExecutor, + StacksDisputeExecutor, +} from './dispute-executor.js'; +import { + AcceptAllSignatureVerifier, + ReadOnlySignatureVerifier, + RejectAllSignatureVerifier, +} from './signature-verifier.js'; +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, +} from './types.js'; +import { + PrincipalNotWatchedError, + SignatureValidationError, + StackflowNode, +} from './stackflow-node.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 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': { + 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, + extraHeaders: Record = {}, +): void { + response.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + ...extraHeaders, + }); + response.end(JSON.stringify(payload)); +} + +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'; + } + + 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 normalizeWatchedContracts(watchedContracts: string[]): string[] { + return watchedContracts + .map((contract) => contract.trim().toLowerCase()) + .filter((contract) => contract.length > 0); +} + +function contractMatches(contractId: string, watchedContracts: string[]): boolean { + if (watchedContracts.length > 0) { + const normalizedContractId = contractId.toLowerCase(); + return watchedContracts.some((candidate) => candidate === normalizedContractId); + } + return DEFAULT_STACKFLOW_CONTRACT_PATTERN.test(contractId); +} + +function extractRawStackflowPrintEventSamples( + payload: unknown, + watchedContracts: string[], +): Record[] { + const normalizedWatchedContracts = normalizeWatchedContracts(watchedContracts); + 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, normalizedWatchedContracts) + ) { + 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: StackflowNodeStatus, + 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 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, + counterpartyEnabled, + counterpartyPrincipal, + 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; + counterpartyEnabled: boolean; + counterpartyPrincipal: string | null; + 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, + ): 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 = stackflowNode.status(); + + writeJson(response, 200, { + ok: true, + startedAt, + updatedAt: status.updatedAt, + activeClosures: status.activeClosures.length, + observedPipes: status.observedPipes.length, + signatureStates: status.signatureStates.length, + disputeEnabled, + 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; + } + + if (method === 'GET' && url.pathname === '/closures') { + const status = stackflowNode.status(); + writeJson(response, 200, { + ok: true, + closures: status.activeClosures, + }); + return; + } + + 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, + redacted: shouldRedact, + signatureStates: shouldRedact + ? signatureStates.map((state) => + redactSignatureState(state as unknown as Record), + ) + : signatureStates, + }); + return; + } + + if (method === 'GET' && url.pathname === '/pipes') { + const status = stackflowNode.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 = stackflowNode.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 = stackflowNode.status(); + const limit = parseLimit(url); + writeJson(response, 200, { + ok: true, + events: status.recentEvents.slice(0, limit), + }); + 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); + + 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( + `[stackflow-node] /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( + `[stackflow-node] /signature-states rejected status=401 error=${error.message}`, + ); + writeJson(response, 401, { + ok: false, + error: error.message, + }); + return; + } + + if (error instanceof PrincipalNotWatchedError) { + console.warn( + `[stackflow-node] /signature-states rejected status=403 error=${error.message}`, + ); + writeJson(response, 403, { + ok: false, + error: error.message, + }); + return; + } + + console.warn( + `[stackflow-node] /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 === '/counterparty/transfer') { + 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.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( + `[stackflow-node] /counterparty/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, + ); + writeJson(response, error.statusCode, body); + return; + } + + console.error( + `[stackflow-node] /counterparty/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 === '/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}`, + ); + 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( + `[stackflow-node] /counterparty/signature-request 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, + ); + 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, + 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] /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 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( + `[stackflow-node] /new_block received ${summarizeNewBlockPayload(payload)}`, + ); + if (logRawEvents) { + const samples = extractRawStackflowPrintEventSamples( + payload, + watchedContracts, + ); + console.log( + `[stackflow-node] /new_block raw stackflow events count=${samples.length}`, + ); + for (const [index, sample] of samples.entries()) { + console.log( + `[stackflow-node] /new_block raw stackflow event[${index}] ${stringifyForLog(sample)}`, + ); + } + } + const result = await stackflowNode.ingest(payload, url.pathname); + console.log( + `[stackflow-node] /new_block processed observedEvents=${result.observedEvents} activeClosures=${result.activeClosures}`, + ); + writeJson(response, 200, { ok: true, ...result }); + } catch (error) { + console.error( + `[stackflow-node] /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') { + 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); + + if (!burnBlockHeight) { + console.warn('[stackflow-node] /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 stackflowNode.ingestBurnBlock(burnBlockHeight, url.pathname); + writeJson(response, 200, { + ok: true, + ...result, + }); + } catch (error) { + console.error( + `[stackflow-node] /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', + }); + }; +} + +async function start(): Promise { + 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.disputeSignerKey + ? 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 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: effectiveWatchedPrincipals, + disputeExecutor, + disputeOnlyBeneficial: config.disputeOnlyBeneficial, + signatureVerifier, + }); + const counterpartyService = new CounterpartyService({ + 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, + counterpartyEnabled: counterpartyService.enabled, + counterpartyPrincipal: counterpartyService.counterpartyPrincipal, + 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 + ? config.watchedContracts.join(', ') + : '[auto: any *.stackflow* contract]'; + const watchedPrincipals = + 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} ` + + `contracts=${watchedContracts} db=${config.dbFile} ` + + `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 ?? '-' + }`, + ); + + if (config.signatureVerifierMode !== 'readonly') { + console.warn( + `[stackflow-node] non-readonly signature verifier mode active: ${config.signatureVerifierMode}`, + ); + } + + if (config.disputeExecutorMode !== 'auto') { + console.warn( + `[stackflow-node] non-auto dispute executor mode active: ${config.disputeExecutorMode}`, + ); + } + + if (config.logRawEvents) { + 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( + `[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}`, + ); + } + } + }); + + let shuttingDown = false; + const shutdown = (signal: string): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + + console.log(`[stackflow-node] received ${signal}, shutting down`); + clearInterval(retryInterval); + server.close(() => { + stateStore.close(); + console.log('[stackflow-node] shutdown complete'); + process.exit(0); + }); + + setTimeout(() => { + console.error('[stackflow-node] forced shutdown timeout reached'); + stateStore.close(); + process.exit(1); + }, 10000).unref(); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); +} + +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/observer-parser.ts b/server/src/observer-parser.ts new file mode 100644 index 0000000..6da3731 --- /dev/null +++ b/server/src/observer-parser.ts @@ -0,0 +1,405 @@ +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 normalizeWatchedContracts(watchedContracts: string[]): string[] { + return watchedContracts + .map((contract) => contract.trim()) + .filter((contract) => contract.length > 0); +} + +function contractMatches( + contractId: string | null, + watchedContracts: string[], +): boolean { + if (!contractId) { + return false; + } + + if (watchedContracts.length > 0) { + return watchedContracts.some((candidate) => candidate === 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 = normalizeWatchedContracts(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/signature-verifier.ts b/server/src/signature-verifier.ts new file mode 100644 index 0000000..48ca223 --- /dev/null +++ b/server/src/signature-verifier.ts @@ -0,0 +1,217 @@ +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, + StackflowNodeConfig, +} from './types.js'; + +const ACTION_TRANSFER = '1'; + +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 isTransfer = input.action === ACTION_TRANSFER; + const response = await fetchCallReadOnlyFunction({ + network: this.network, + senderAddress: senderAddressForPrincipal(input.forPrincipal), + contractAddress: contract.address, + contractName: contract.name, + 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) { + 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/stackflow-node.ts b/server/src/stackflow-node.ts new file mode 100644 index 0000000..9a3780b --- /dev/null +++ b/server/src/stackflow-node.ts @@ -0,0 +1,830 @@ +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, + RecordedStackflowNodeEvent, + SignatureStateInput, + SignatureStateRecord, + SignatureStateUpsertResult, + SignatureVerifier, + StackflowPrintEvent, + StackflowNodeStatus, +} from './types.js'; + +interface StackflowNodeOptions { + 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_TRANSFER = '1'; +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 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) + : 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: action === ACTION_TRANSFER + ? (hashedSecret ?? secret) + : 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 StackflowNode { + 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, + }: StackflowNodeOptions) { + 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( + `[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] signature-state processed result=stored replaced=true ${pipeContext}`, + ); + return { + stored: true, + replaced: true, + reason: null, + state: nextState, + }; + } + + this.stateStore.setSignatureState(nextState); + console.log( + `[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] pending settled pipeId=${observedPipe.pipeId} burnBlock=${burnBlockHeight.toString( + 10, + )} balance1=${nextPipe.balance1 ?? '-'} balance2=${nextPipe.balance2 ?? '-'}`, + ); + } + + console.log( + `[stackflow-node] 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: RecordedStackflowNodeEvent = { + ...event, + source, + observedAt: new Date().toISOString(), + }; + + this.stateStore.recordEvent(processedEvent); + console.log( + `[stackflow-node] event recorded event=${event.eventName ?? 'unknown'} txid=${event.txid ?? '-'} source=${ + source ?? 'unknown' + }`, + ); + + if (!event.pipeKey || !event.eventName) { + console.log('[stackflow-node] event skipped reason=missing-pipe-or-event-name'); + return; + } + + const pipeId = normalizePipeId(event.pipeKey); + if (!pipeId) { + console.log('[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] 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( + `[stackflow-node] dispute skipped reason=executor-disabled pipeId=${closure.pipeId} contract=${closure.contractId}`, + ); + return; + } + + const closureNonce = toBigInt(closure.nonce); + if (closureNonce === null) { + console.log( + `[stackflow-node] dispute skipped reason=missing-closure-nonce pipeId=${closure.pipeId} contract=${closure.contractId}`, + ); + return; + } + + const closer = closure.closer; + if (!closer) { + console.log( + `[stackflow-node] 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( + `[stackflow-node] dispute skipped reason=no-counterparty-state pipeId=${closure.pipeId} contract=${closure.contractId} closer=${closer}`, + ); + return; + } + + console.log( + `[stackflow-node] 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) { + if (state.action === ACTION_TRANSFER && state.secret) { + if (this.stateStore.hasForwardingPaymentHash(state.secret)) { + return this.stateStore.getRevealedSecretByHash(state.secret) !== null; + } + } + return true; + } + + const closureBalance = getClosureSideBalance(triggerEvent, state.forPrincipal); + if (closureBalance === null) { + 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); + }); + + if (!eligible) { + console.log( + `[stackflow-node] 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( + `[stackflow-node] dispute skipped reason=already-submitted pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} attemptId=${attemptId}`, + ); + return; + } + + console.log( + `[stackflow-node] dispute submit pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} nonce=${eligible.nonce} triggerTxid=${triggerEvent.txid ?? '-'} mode=${ + this.disputeExecutor.constructor.name + }`, + ); + + 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, + }); + + 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( + `[stackflow-node] 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( + `[stackflow-node] dispute failed pipeId=${closure.pipeId} contract=${closure.contractId} for=${eligible.forPrincipal} error=${attempt.error}`, + ); + } + } + + status(): StackflowNodeStatus { + 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/src/state-store.ts b/server/src/state-store.ts new file mode 100644 index 0000000..ffaa6d2 --- /dev/null +++ b/server/src/state-store.ts @@ -0,0 +1,1665 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import type { + ClosureRecord, + DisputeAttemptRecord, + ForwardingPaymentRecord, + IdempotentResponseRecord, + ObservedPipeRecord, + PipeKey, + RecordedStackflowNodeEvent, + SignatureStateRecord, + StackflowNodePersistedState, +} from './types.js'; + +interface SqliteStateStoreOptions { + dbFile: string; + 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; + 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; +} + +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; + } + + 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( + `[stackflow-node] 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 + ); + + 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 (?, ?)', + ); + setMeta.run('version', '1'); + setMeta.run('updated_at', ''); + + if (legacyState) { + this.importLegacyState(legacyState); + } + + this.pruneIdempotentResponses(); + this.pruneForwardingPaymentOrphans(); + this.pruneForwardingPaymentsLatestPerPipe(); + } + + 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 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; + } + + 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: StackflowNodePersistedState): 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, + }; + } + + 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(); + + 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: RecordedStackflowNodeEvent[] = []; + for (const row of eventRows) { + try { + const parsed = JSON.parse(row.event_json) as RecordedStackflowNodeEvent; + 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: RecordedStackflowNodeEvent): 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)); + } + + 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 new file mode 100644 index 0000000..d5ea737 --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,269 @@ +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 StackflowNodePersistedState { + version: number; + updatedAt: string | null; + activeClosures: Record; + observedPipes: Record; + signatureStates: Record; + disputeAttempts: Record; + 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; +} + +export interface StackflowNodeStatus { + version: number; + updatedAt: string | null; + activeClosures: ClosureRecord[]; + observedPipes: ObservedPipeRecord[]; + signatureStates: SignatureStateRecord[]; + disputeAttempts: DisputeAttemptRecord[]; + recentEvents: RecordedStackflowNodeEvent[]; +} + +export interface IngestResult { + observedEvents: number; + activeClosures: number; +} + +export type SignatureVerifierMode = + | 'readonly' + | 'accept-all' + | 'reject-all'; + +export type DisputeExecutorMode = + | 'auto' + | 'noop' + | 'mock'; + +export type CounterpartySignerMode = + | 'local-key' + | 'kms'; + +export interface StackflowNodeConfig { + host: string; + port: number; + dbFile: string; + maxRecentEvents: number; + logRawEvents: boolean; + watchedContracts: string[]; + watchedPrincipals: string[]; + stacksNetwork: 'mainnet' | 'testnet' | 'devnet' | 'mocknet'; + stacksApiUrl: string | null; + 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; + 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 { + txid: string; +} + +export interface DisputeExecutor { + readonly enabled: boolean; + readonly signerAddress: string | null; + submitDispute(args: { + signatureState: SignatureStateRecord; + resolvedSecret: string | null; + closure: ClosureRecord; + triggerEvent: StackflowPrintEvent; + }): Promise; +} 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); +}); 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..196c956 --- /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..1ee0179 --- /dev/null +++ b/server/ui/main.js @@ -0,0 +1,1623 @@ +// 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 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", + 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-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", + "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-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", + "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-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", + "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-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", + "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 server.", + 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-counterparty-transfer": { + endpoint: "/counterparty/transfer", + action: "1" + }, + "request-counterparty-close": { + endpoint: "/counterparty/signature-request", + action: "0" + }, + "request-counterparty-deposit": { + endpoint: "/counterparty/signature-request", + action: "2" + }, + "request-counterparty-withdrawal": { + endpoint: "/counterparty/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-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 getCounterpartyActionConfig(action) { + return PRODUCER_ACTION_CONFIG[action] || null; +} +function isCounterpartyRequestAction(action) { + return Boolean(getCounterpartyActionConfig(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 (isCounterpartyRequestAction(action) && !normalizedText(getInput(ids.sigWith).value) && stackflowNodeCounterpartyPrincipal) { + getInput(ids.sigWith).value = stackflowNodeCounterpartyPrincipal; + } + let counterpartyHint = ""; + if (isCounterpartyRequestAction(action)) { + if (stackflowNodeCounterpartyEnabled && stackflowNodeCounterpartyPrincipal) { + counterpartyHint = ` Counterparty principal: ${stackflowNodeCounterpartyPrincipal}.`; + } else { + counterpartyHint = " Counterparty signing is not reported as enabled by the server."; + } + } + setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${counterpartyHint}`, false); + setSignedActionForSelection(action); +} +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("."); + 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 = { + serverUrl: getInput(ids.serverUrl).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.serverUrl === "string") { + getInput(ids.serverUrl).value = parsed.serverUrl; + } + 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.serverUrl).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 buildStackflowNodePayload() { + 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 buildCounterpartyRequestPayload(action) { + const config = getCounterpartyActionConfig(action); + if (!config) { + throw new Error(`Unsupported counterparty 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 (isCounterpartyRequestAction(action)) { + try { + const request2 = buildCounterpartyRequestPayload(action); + $(ids.signaturePayload).textContent = JSON.stringify(request2, null, 2); + } catch (error) { + $(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 counterparty requests."; + return; + } + try { + const payload = buildStackflowNodePayload(); + $(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.serverUrl).value); + if (!baseUrl) { + throw new Error("Server 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.serverUrl).value); + if (!baseUrl) { + throw new Error("Server 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 = buildStackflowNodePayload(); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); + if (!baseUrl) { + throw new Error("Server 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 requestCounterpartySignature(action) { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); + if (!baseUrl) { + throw new Error("Server URL is required"); + } + const requestPayload = buildCounterpartyRequestPayload(action); + const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { + method: "POST", + headers: createPeerProtocolHeaders(), + 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, + `Counterparty request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + true + ); + 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); + } + const counterpartySignature = normalizeHex( + body?.mySignature, + "Counterparty signature", + 65 + ); + getInput(ids.sigTheirSignature).value = counterpartySignature; + renderPayloadPreview(); + setStatus( + ids.txResult, + `Counterparty signature received (stored=${body.stored}, replaced=${body.replaced}).` + ); + await refreshPipes(); +} +function bindInputs() { + const configIds = [ + ids.serverUrl, + ids.contractId, + ids.network, + ids.contractVersion + ]; + for (const id of configIds) { + getInput(id).addEventListener("change", saveConfig); + } + getInput(ids.serverUrl).addEventListener("change", async () => { + await syncNetworkFromStackflowNode(); + }); + 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 syncNetworkFromStackflowNode() { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); + if (!baseUrl) { + return; + } + try { + const health = await fetchJson(`${baseUrl}/health`); + stackflowNodeCounterpartyEnabled = Boolean(health?.counterpartyEnabled); + stackflowNodeCounterpartyPrincipal = typeof health?.counterpartyPrincipal === "string" && normalizedText(health.counterpartyPrincipal) ? health.counterpartyPrincipal : 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 server: ${remoteNetwork}` + ); + } + if (isCounterpartyRequestAction(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 (isCounterpartyRequestAction(action)) { + await requestCounterpartySignature(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 syncNetworkFromStackflowNode(); + 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..36689a6 --- /dev/null +++ b/server/ui/main.src.js @@ -0,0 +1,1910 @@ +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 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", + 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-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", + "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-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", + "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-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", + "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-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", + "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 server.", + 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-counterparty-transfer": { + endpoint: "/counterparty/transfer", + action: "1", + }, + "request-counterparty-close": { + endpoint: "/counterparty/signature-request", + action: "0", + }, + "request-counterparty-deposit": { + endpoint: "/counterparty/signature-request", + action: "2", + }, + "request-counterparty-withdrawal": { + endpoint: "/counterparty/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-counterparty-close": "0", + "request-counterparty-transfer": "1", + "request-counterparty-deposit": "2", + "request-counterparty-withdrawal": "3", + }; + + const value = mapping[action]; + if (value !== undefined) { + getInput(ids.sigAction).value = value; + } +} + +function getCounterpartyActionConfig(action) { + return PRODUCER_ACTION_CONFIG[action] || null; +} + +function isCounterpartyRequestAction(action) { + return Boolean(getCounterpartyActionConfig(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 ( + isCounterpartyRequestAction(action) && + !normalizedText(getInput(ids.sigWith).value) && + stackflowNodeCounterpartyPrincipal + ) { + getInput(ids.sigWith).value = stackflowNodeCounterpartyPrincipal; + } + + let counterpartyHint = ""; + if (isCounterpartyRequestAction(action)) { + if (stackflowNodeCounterpartyEnabled && stackflowNodeCounterpartyPrincipal) { + counterpartyHint = ` Counterparty principal: ${stackflowNodeCounterpartyPrincipal}.`; + } else { + counterpartyHint = + " Counterparty signing is not reported as enabled by the server."; + } + } + + setStatus(ids.actionHelp, `Action: ${action}. ${def.help}${counterpartyHint}`, false); + setSignedActionForSelection(action); +} + +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("."); + 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 = { + serverUrl: getInput(ids.serverUrl).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.serverUrl === "string") { + getInput(ids.serverUrl).value = parsed.serverUrl; + } + 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.serverUrl).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 buildStackflowNodePayload() { + 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 buildCounterpartyRequestPayload(action) { + const config = getCounterpartyActionConfig(action); + if (!config) { + throw new Error(`Unsupported counterparty 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 (isCounterpartyRequestAction(action)) { + try { + const request = buildCounterpartyRequestPayload(action); + $(ids.signaturePayload).textContent = JSON.stringify(request, null, 2); + } catch (error) { + $(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 counterparty requests."; + return; + } + + try { + const payload = buildStackflowNodePayload(); + $(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.serverUrl).value); + if (!baseUrl) { + throw new Error("Server 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.serverUrl).value); + if (!baseUrl) { + throw new Error("Server 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 = buildStackflowNodePayload(); + const baseUrl = normalizedText(getInput(ids.serverUrl).value); + if (!baseUrl) { + throw new Error("Server 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 requestCounterpartySignature(action) { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); + if (!baseUrl) { + throw new Error("Server URL is required"); + } + + const requestPayload = buildCounterpartyRequestPayload(action); + const response = await fetch(`${baseUrl}${requestPayload.endpoint}`, { + method: "POST", + headers: createPeerProtocolHeaders(), + 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, + `Counterparty request rejected: nonce must be higher (incoming ${incomingNonce}, existing ${existingNonce}).`, + true, + ); + 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); + } + + const counterpartySignature = normalizeHex( + body?.mySignature, + "Counterparty signature", + 65, + ); + getInput(ids.sigTheirSignature).value = counterpartySignature; + renderPayloadPreview(); + setStatus( + ids.txResult, + `Counterparty signature received (stored=${body.stored}, replaced=${body.replaced}).`, + ); + await refreshPipes(); +} + +function bindInputs() { + const configIds = [ + ids.serverUrl, + ids.contractId, + ids.network, + ids.contractVersion, + ]; + for (const id of configIds) { + getInput(id).addEventListener("change", saveConfig); + } + getInput(ids.serverUrl).addEventListener("change", async () => { + await syncNetworkFromStackflowNode(); + }); + 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 syncNetworkFromStackflowNode() { + const baseUrl = normalizedText(getInput(ids.serverUrl).value); + if (!baseUrl) { + return; + } + + try { + const health = await fetchJson(`${baseUrl}/health`); + stackflowNodeCounterpartyEnabled = Boolean(health?.counterpartyEnabled); + stackflowNodeCounterpartyPrincipal = + typeof health?.counterpartyPrincipal === "string" && + normalizedText(health.counterpartyPrincipal) + ? health.counterpartyPrincipal + : 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 server: ${remoteNetwork}`, + ); + } + + if (isCounterpartyRequestAction(getSelectedAction())) { + updateActionUi(); + renderPayloadPreview(); + } + } catch { + // Ignore; server 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 (isCounterpartyRequestAction(action)) { + await requestCounterpartySignature(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 syncNetworkFromStackflowNode(); + 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/settings/Devnet.toml b/settings/Devnet.toml index 54a2c35..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 @@ -79,7 +83,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 +110,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 @@ -154,4 +158,3 @@ auto_extend = true wallet = "wallet_3" slots = 2 btc_address = "mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7" - diff --git a/tests/counterparty-service.test.ts b/tests/counterparty-service.test.ts new file mode 100644 index 0000000..c6f0d9c --- /dev/null +++ b/tests/counterparty-service.test.ts @@ -0,0 +1,544 @@ +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 { + 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 { 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(); + 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 counterpartyAddress = getAddressFromPrivateKey( + PRODUCER_KEY, + createNetwork({ network: 'devnet' }), + ); + const dbFile = path.join( + os.tmpdir(), + `stackflow-counterparty-${Date.now()}-${Math.random()}.db`, + ); + const store = new SqliteStateStore({ dbFile, maxRecentEvents: 20 }); + store.load(); + + const stackflowNode = new StackflowNode({ + stateStore: store, + watchedPrincipals: [counterpartyAddress], + signatureVerifier: new AcceptAllSignatureVerifier(), + }); + const signer = new CounterpartyStateSigner({ + stacksNetwork: 'devnet', + stacksApiUrl: null, + signatureVerifierMode, + counterpartyKey: PRODUCER_KEY, + counterpartyPrincipal: null, + stackflowMessageVersion: '0.6.0', + }); + const service = new CounterpartyService({ stackflowNode, signer }); + + return { + counterpartyAddress, + dbFile, + store, + service, + }; +} + +function transferPayload(counterpartyAddress: string) { + return { + contractId: CONTRACT_ID, + withPrincipal: COUNTERPARTY, + token: null, + myBalance: '900', + theirBalance: '100', + theirSignature: COUNTERPARTY_SIGNATURE, + nonce: '5', + action: '1', + actor: counterpartyAddress, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +function signatureRequestPayload( + counterpartyAddress: string, + overrides: Record = {}, +) { + return { + ...transferPayload(counterpartyAddress), + action: '0', + amount: '0', + actor: COUNTERPARTY, + ...overrides, + }; +} + +function seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal, + token, + contractId, + myBalance, + theirBalance, + nonce, +}: { + store: SqliteStateStore; + counterpartyAddress: string; + withPrincipal: string; + token: string | null; + contractId: string; + myBalance: string; + theirBalance: string; + nonce: string; +}): void { + const pipeKey = canonicalPipeKey(token, counterpartyAddress, withPrincipal); + const pipeId = normalizePipeId(pipeKey); + if (!pipeId) { + throw new Error('failed to build pipe id in test'); + } + + 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}`, + 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('counterparty signing service', () => { + it('signs transfer states and stores the latest signature pair', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '800', + theirBalance: '200', + nonce: '4', + }); + + const result = await service.signTransfer(transferPayload(counterpartyAddress)); + + expect(result.upsert.stored).toBe(true); + expect(result.upsert.replaced).toBe(false); + 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); + expect(result.upsert.state.theirSignature).toBe(COUNTERPARTY_SIGNATURE); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('enforces action restrictions on /counterparty/signature-request', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({}); + + try { + await expect( + service.signSignatureRequest({ + ...transferPayload(counterpartyAddress), + action: '1', + }), + ).rejects.toMatchObject>({ + statusCode: 400, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('rejects requests when reject-all verifier mode is active', async () => { + const { counterpartyAddress, dbFile, store, service } = makeHarness({ + signatureVerifierMode: 'reject-all', + }); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '800', + theirBalance: '200', + nonce: '4', + }); + + await expect( + service.signTransfer(transferPayload(counterpartyAddress)), + ).rejects.toMatchObject>({ + statusCode: 401, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + it('requires amount for 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', + }); + + await expect( + service.signSignatureRequest({ + ...transferPayload(counterpartyAddress), + action: '3', + actor: COUNTERPARTY, + amount: null, + myBalance: '200', + theirBalance: '50', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 400, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + 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({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '800', + theirBalance: '200', + nonce: '4', + }); + + const first = await service.signTransfer(transferPayload(counterpartyAddress)); + expect(first.upsert.stored).toBe(true); + + await expect( + service.signTransfer(transferPayload(counterpartyAddress)), + ).rejects.toMatchObject>({ + statusCode: 409, + details: { + reason: 'nonce-too-low', + existingNonce: '5', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + 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({}); + + try { + seedObservedPipeState({ + store, + counterpartyAddress, + withPrincipal: COUNTERPARTY, + token: null, + contractId: CONTRACT_ID, + myBalance: '200', + theirBalance: '100', + nonce: '4', + }); + + await expect( + service.signTransfer({ + ...transferPayload(counterpartyAddress), + myBalance: '150', + theirBalance: '150', + nonce: '5', + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'counterparty-balance-decrease', + }, + }); + } finally { + cleanupDb(store, dbFile); + } + }); + + 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({}); + + try { + await expect( + service.signTransfer(transferPayload(counterpartyAddress)), + ).rejects.toMatchObject>({ + statusCode: 409, + details: { + reason: 'unknown-pipe-state', + }, + }); + } finally { + 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/forwarding-service.test.ts b/tests/forwarding-service.test.ts new file mode 100644 index 0000000..498cddf --- /dev/null +++ b/tests/forwarding-service.test.ts @@ -0,0 +1,240 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ForwardingService, + ForwardingServiceError, +} from '../server/src/forwarding-service.ts'; + +const CONTRACT_ID = 'ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0'; +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; +const SIG_B = `0x${'22'.repeat(65)}`; +const HASHED_SECRET = + '0x46d74c485561af789b3e3f76ea7eb83db34b07dabe75cb50c9910c4d161c42fb'; +const VALID_SECRET = + '0x8484848484848484848484848484848484848484848484848484848484848484'; + +function makeTransferPayload() { + return { + contractId: CONTRACT_ID, + forPrincipal: P1, + withPrincipal: P2, + token: null, + amount: '0', + myBalance: '910', + theirBalance: '90', + theirSignature: SIG_B, + nonce: '6', + action: '1', + actor: P2, + secret: null, + validAfter: null, + beneficialOnly: false, + }; +} + +describe('forwarding service', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('normalizes transfer payloads and signs incoming update after next-hop accepts', async () => { + const signTransfer = vi.fn().mockResolvedValue({ + request: makeTransferPayload(), + mySignature: `0x${'11'.repeat(65)}`, + upsert: { + stored: true, + replaced: false, + state: { + mySignature: `0x${'11'.repeat(65)}`, + theirSignature: SIG_B, + }, + }, + }); + + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response( + JSON.stringify({ + mySignature: `0x${'33'.repeat(65)}`, + theirSignature: SIG_B, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer, + } as any, + config: { + enabled: true, + minFee: '5', + timeoutMs: 1_000, + allowPrivateDestinations: true, + allowedBaseUrls: ['https://next-hop.example/'], + }, + }); + + const result = await service.processTransfer({ + paymentId: 'pay-2026-03-06-0001', + incomingAmount: '100', + outgoingAmount: '90', + hashedSecret: HASHED_SECRET, + incoming: makeTransferPayload(), + outgoing: { + baseUrl: 'https://next-hop.example', + payload: makeTransferPayload(), + }, + }); + + expect(result.feeAmount).toBe('10'); + expect(result.nextHopBaseUrl).toBe('https://next-hop.example'); + expect(result.hashedSecret).toBe(HASHED_SECRET); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://next-hop.example/counterparty/transfer'); + expect(init.method).toBe('POST'); + + const nextHopBody = JSON.parse(String(init.body)) as Record; + expect(nextHopBody.hashedSecret).toBe(HASHED_SECRET); + expect(nextHopBody.secret).toBe(HASHED_SECRET); + + expect(signTransfer).toHaveBeenCalledTimes(1); + const [incomingPayload] = signTransfer.mock.calls[0] as [Record]; + expect(incomingPayload.hashedSecret).toBe(HASHED_SECRET); + expect(incomingPayload.secret).toBe(HASHED_SECRET); + }); + + it('rejects private next-hop destinations by default', async () => { + const signTransfer = vi.fn(); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer, + } as any, + config: { + enabled: true, + minFee: '1', + timeoutMs: 1_000, + allowPrivateDestinations: false, + allowedBaseUrls: [], + }, + }); + + await expect( + service.processTransfer({ + paymentId: 'pay-2026-03-06-0002', + incomingAmount: '100', + outgoingAmount: '99', + hashedSecret: HASHED_SECRET, + incoming: makeTransferPayload(), + outgoing: { + baseUrl: 'http://127.0.0.1:3999', + payload: makeTransferPayload(), + }, + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'next-hop-private-destination', + }, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(signTransfer).not.toHaveBeenCalled(); + }); + + it('rejects negative forwarding fee before side effects', async () => { + const signTransfer = vi.fn(); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer, + } as any, + config: { + enabled: true, + minFee: '1', + timeoutMs: 1_000, + allowPrivateDestinations: true, + allowedBaseUrls: ['https://next-hop.example'], + }, + }); + + await expect( + service.processTransfer({ + paymentId: 'pay-2026-03-06-0003', + incomingAmount: '90', + outgoingAmount: '100', + hashedSecret: HASHED_SECRET, + incoming: makeTransferPayload(), + outgoing: { + baseUrl: 'https://next-hop.example', + payload: makeTransferPayload(), + }, + }), + ).rejects.toMatchObject>({ + statusCode: 403, + details: { + reason: 'negative-forwarding-fee', + }, + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(signTransfer).not.toHaveBeenCalled(); + }); + + it('verifies reveal preimage secrets', () => { + const service = new ForwardingService({ + counterpartyService: { + enabled: true, + counterpartyPrincipal: P1, + signTransfer: vi.fn(), + } as any, + config: { + enabled: true, + minFee: '1', + timeoutMs: 1_000, + allowPrivateDestinations: true, + allowedBaseUrls: [], + }, + }); + + expect( + service.verifyRevealSecret({ + hashedSecret: HASHED_SECRET, + secret: VALID_SECRET, + }), + ).toEqual({ + hashedSecret: HASHED_SECRET, + secret: VALID_SECRET, + }); + + expect(() => + service.verifyRevealSecret({ + hashedSecret: HASHED_SECRET, + secret: '0x1111111111111111111111111111111111111111111111111111111111111111', + }), + ).toThrowError(ForwardingServiceError); + + expect(() => + service.verifyRevealSecret({ + hashedSecret: HASHED_SECRET, + secret: '0x1111111111111111111111111111111111111111111111111111111111111111', + }), + ).toThrow(/secret does not match hashedSecret/i); + }); +}); diff --git a/tests/reservoir.test.ts b/tests/reservoir.test.ts new file mode 100644 index 0000000..cd5d855 --- /dev/null +++ b/tests/reservoir.test.ts @@ -0,0 +1,1948 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { Cl, ClarityType, ResponseOkCV } from "@stacks/transactions"; +import { + deployer, + address1, + address2, + address1PK, + address2PK, + reservoirContract, + StackflowError, + generateDepositSignature, + ReservoirError, + stackflowContract, + deployerPK, + MAX_HEIGHT, + CONFIRMATION_DEPTH, + generateWithdrawSignature, + BORROW_TERM_BLOCKS, + PipeAction, + generateTransferSignature, +} 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.principal(stackflowContract), Cl.none(), Cl.uint(0)], + deployer + ); + }); + + 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( + "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", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], + address1 + ); + + // Add liquidity so amount check passes and we reach fee validation + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000)], + deployer + ); + + 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.principal(stackflowContract), + 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(1000000000)], + deployer + ); + expect(result).toBeOk(Cl.bool(true)); + + // Verify reservoir balance + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(1000000000n); + + // Verify get-available-liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + 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( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(10000000000)], + deployer + ); + + // Then remove some (leaving more than the minimum) + const { result } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity", + [Cl.none(), Cl.uint(800000), Cl.principal(deployer)], + deployer + ); + expect(result).toBeOk(Cl.uint(800000)); + + // Verify balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(9999200000n); + + // Verify get-available-liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(available).toBeOk(Cl.uint(9999200000n)); + }); + + it("operator can remove their own unused STX liquidity to another address", () => { + // First add liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(10000000000)], + deployer + ); + + // Then remove some (leaving more than the minimum) + const { result } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity", + [Cl.none(), Cl.uint(800000), Cl.principal(address1)], + deployer + ); + expect(result).toBeOk(Cl.uint(800000)); + + // Verify balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(9999200000n); + + // Verify get-available-liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(available).toBeOk(Cl.uint(9999200000n)); + }); + + it("operator cannot remove more than the available liquidity", () => { + // Add liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000000)], + deployer + ); + + // Try to remove more than available + const { result } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity", + [Cl.none(), Cl.uint(2000000000), Cl.principal(deployer)], + deployer + ); + 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 get-available-liquidity remains unchanged + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(available).toBeOk(Cl.uint(1000000000n)); + }); + + it("non-operator cannot remove liquidity", () => { + // First add liquidity as deployer + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(2000000000)], + deployer + ); + + // Try to remove as non-operator + const { result } = simnet.callPublicFn( + "reservoir", + "withdraw-liquidity", + [Cl.none(), Cl.uint(500000), Cl.principal(address1)], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.Unauthorized)); + }); + + it("calculates available liquidity correctly", () => { + // Add liquidity from first provider + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(2000000000)], + deployer + ); + + // Check total liquidity + const { result: available } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(available).toBeOk(Cl.uint(2000000000)); + + // Add liquidity again + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1500000000)], + deployer + ); + + // Check updated total liquidity + const { result: availableAfterAdd } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(availableAfterAdd).toBeOk(Cl.uint(3500000000)); + + // Remove some liquidity + simnet.callPublicFn( + "reservoir", + "withdraw-liquidity", + [Cl.none(), Cl.uint(500000000), Cl.principal(deployer)], + deployer + ); + + // Check updated total liquidity after removal + const { result: availableAfterWithdraw } = simnet.callPublicFn( + "reservoir", + "get-available-liquidity", + [Cl.none()], + deployer + ); + expect(availableAfterWithdraw).toBeOk(Cl.uint(3000000000)); + }); + }); + + describe("tap management", () => { + it("can fund new tap", () => { + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + 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(5000000000)], + deployer + ); + + // Fund initial tap + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + const pipeKey = (result as ResponseOkCV).value; + + 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) + ); + + // Verify balances + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + // 5000000000 - 50000 (borrowed) + 5000 (fee) + expect(reservoirBalance).toBe(4999955000n); + const tapBalance = stxBalances.get(stackflowContract); + // 1000000 (initial) + 50000 (borrowed) + expect(tapBalance).toBe(1050000n); + + // 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(50000), + "burn-height": Cl.uint( + simnet.burnBlockHeight + CONFIRMATION_DEPTH + ), + }) + ), + }) + ); + }); + + it("can borrow liquidity with zero fee when borrow rate is zero", () => { + // borrow-rate is u0 from init + const { result: feeResult } = simnet.callReadOnlyFn( + "reservoir", + "get-borrow-fee", + [Cl.uint(50000)], + deployer + ); + expect(feeResult).toBeUint(0); + + // Add liquidity to reservoir + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(5000000000)], + deployer + ); + + // Fund initial tap + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(1000000), + Cl.uint(0), + ], + address1 + ); + expect(result.type).toBe(ClarityType.ResponseOk); + + const amount = 50000; + const fee = 0; + + 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) + ); + + // 5000000000 - 50000 (borrowed), with no fee transfer + const stxBalances = simnet.getAssetsMap().get("STX")!; + const reservoirBalance = stxBalances.get(reservoirContract); + expect(reservoirBalance).toBe(4999950000n); + }); + + 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( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + + // Add small amount of liquidity + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(1000000000)], + deployer + ); + + // Fund initial tap + simnet.callPublicFn( + "reservoir", + "create-tap", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(50000), + Cl.uint(0), + ], + address1 + ); + + // Try to borrow more than available + const amount = 10000000000; + const fee = 1000000000; + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + 50000, + amount, + 1, + address1 + ); + + const reservoirSignature = generateDepositSignature( + address2PK, + null, + reservoirContract, + address1, + amount, + 50000, + 1, + address1 + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "borrow-liquidity", + [ + Cl.principal(stackflowContract), + Cl.uint(amount), + Cl.uint(fee), + Cl.none(), + Cl.uint(50000), + Cl.uint(amount), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(1), + ], + address1 + ); + expect(result).toBeErr(Cl.uint(ReservoirError.AmountNotAvailable)); + }); + }); + + 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)); + }); + }); + + describe("return-liquidity-to-reservoir", () => { + 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 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.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + simnet.mineEmptyBlocks(BORROW_TERM_BLOCKS + 1); + + // 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).toBeOk(Cl.bool(true)); + + // 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); + + // 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", () => { + 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); + }); + + 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("create-tap-with-borrowed-liquidity", () => { + beforeEach(() => { + // Set rate to 10% and add liquidity + simnet.callPublicFn( + "reservoir", + "set-borrow-rate", + [Cl.uint(1000)], + deployer + ); + simnet.callPublicFn( + "reservoir", + "add-liquidity", + [Cl.none(), Cl.uint(5000000000)], + deployer + ); + }); + + it("creates a tap and borrows liquidity in one call", () => { + const tapAmount = 1000000; + const tapNonce = 0; + const borrowAmount = 50000; + const borrowFee = 5000; // 10% + const borrowNonce = 1; + + const mySignature = generateDepositSignature( + address1PK, + null, + address1, + reservoirContract, + tapAmount, + borrowAmount, + borrowNonce, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + borrowAmount, + tapAmount, + borrowNonce, + reservoirContract + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap-with-borrowed-liquidity", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(tapAmount), + Cl.uint(tapNonce), + Cl.uint(borrowAmount), + Cl.uint(borrowFee), + Cl.uint(tapAmount), + Cl.uint(borrowAmount), + Cl.buffer(mySignature), + Cl.buffer(reservoirSignature), + Cl.uint(borrowNonce), + ], + address1 + ); + expect(result).toBeOk( + Cl.uint(simnet.burnBlockHeight + BORROW_TERM_BLOCKS) + ); + + // Verify balances: reservoir funded tap + borrow, minus borrow amount + fee + const stxBalances = simnet.getAssetsMap().get("STX")!; + // reservoir: 5000000000 - borrowAmount + borrowFee + expect(stxBalances.get(reservoirContract)).toBe(4999955000n); + // stackflow: tapAmount + borrowAmount + expect(stxBalances.get(stackflowContract)).toBe(1050000n); + }); + + it("fails if borrow signature is wrong", () => { + const tapAmount = 1000000; + const borrowAmount = 50000; + const borrowFee = 5000; + const borrowNonce = 1; + + // Use a wrong signature (signed with address2's key instead of address1's) + const wrongMySignature = generateDepositSignature( + address2PK, + null, + address1, + reservoirContract, + tapAmount, + borrowAmount, + borrowNonce, + reservoirContract + ); + + const reservoirSignature = generateDepositSignature( + deployerPK, + null, + reservoirContract, + address1, + borrowAmount, + tapAmount, + borrowNonce, + reservoirContract + ); + + const { result } = simnet.callPublicFn( + "reservoir", + "create-tap-with-borrowed-liquidity", + [ + Cl.principal(stackflowContract), + Cl.none(), + Cl.uint(tapAmount), + Cl.uint(0), + Cl.uint(borrowAmount), + Cl.uint(borrowFee), + Cl.uint(tapAmount), + Cl.uint(borrowAmount), + Cl.buffer(wrongMySignature), + Cl.buffer(reservoirSignature), + Cl.uint(borrowNonce), + ], + address1 + ); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidOtherSignature)); + }); + }); + + 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); + }); + + 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-agent.test.ts b/tests/stackflow-agent.test.ts new file mode 100644 index 0000000..2b1caca --- /dev/null +++ b/tests/stackflow-agent.test.ts @@ -0,0 +1,1440 @@ +// @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("skips duplicate disputes for closures already marked disputed", async () => { + const dbFile = tempDbFile("agent-duplicate-dispute"); + 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 agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-dup" }; + }, + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + 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 first = await watcher.runOnce(); + expect(first.disputesSubmitted).toBe(1); + expect(first.skippedAlreadyDisputed).toBe(0); + + const second = await watcher.runOnce(); + expect(second.disputesSubmitted).toBe(0); + expect(second.skippedAlreadyDisputed).toBe(1); + expect(disputeCalls).toBe(1); + + watcher.stop(); + store.close(); + }); + + it("continues readonly polling when one pipe state fetch fails", async () => { + const dbFile = tempDbFile("agent-readonly-fetch-error"); + const store = new AgentStateStore({ dbFile }); + + const contractId = "ST1TESTABC.contract"; + const pipeKeyA = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHERA", + token: null, + }; + const pipeKeyB = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHERB", + token: null, + }; + + const pipeIdA = buildPipeId({ contractId, pipeKey: pipeKeyA }); + const pipeIdB = buildPipeId({ contractId, pipeKey: pipeKeyB }); + + store.upsertTrackedPipe({ + pipeId: pipeIdA, + contractId, + pipeKey: pipeKeyA, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHERA", + token: null, + }); + store.upsertTrackedPipe({ + pipeId: pipeIdB, + contractId, + pipeKey: pipeKeyB, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHERB", + token: null, + }); + + store.upsertSignatureState({ + contractId, + pipeKey: pipeKeyB, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHERB", + 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 errors: Error[] = []; + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-fetch-error" }; + }, + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + let fetchCalls = 0; + const watcher = new HourlyClosureWatcher({ + agentService: agent, + getPipeState: async () => { + fetchCalls += 1; + if (fetchCalls === 1) { + throw new Error("rpc unavailable"); + } + return { + "balance-1": "20", + "balance-2": "80", + "expires-at": "200", + nonce: "5", + closer: "ST1OTHERB", + }; + }, + onError: (error) => { + errors.push(error as Error); + }, + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(true); + expect(result.mode).toBe("readonly-pipe"); + expect(result.pipesScanned).toBe(2); + expect(result.fetchErrors).toBe(1); + expect(result.disputeErrors).toBe(0); + expect(result.disputesSubmitted).toBe(1); + expect(disputeCalls).toBe(1); + expect(errors).toHaveLength(1); + + watcher.stop(); + store.close(); + }); + + it("holds event cursor when dispute submission errors", async () => { + const dbFile = tempDbFile("agent-event-dispute-error"); + 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 submitCalls = 0; + const errors: Error[] = []; + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + submitCalls += 1; + if (submitCalls === 1) { + throw new Error("signer timeout"); + } + return { txid: "0xdispute-ok" }; + }, + async sip018Sign() { + return "0x" + "33".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => [ + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "5", + closer: "ST1OTHER", + txid: "0xtx-err", + blockHeight: "123", + expiresAt: "200", + closureMyBalance: "20", + }, + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "6", + closer: "ST1OTHER", + txid: "0xtx-ok", + blockHeight: "124", + expiresAt: "201", + closureMyBalance: "30", + }, + ], + onError: (error) => { + errors.push(error as Error); + }, + }); + + const first = await watcher.runOnce(); + expect(first.ok).toBe(true); + expect(first.scanned).toBe(2); + expect(first.disputeErrors).toBe(1); + expect(first.disputesSubmitted).toBe(1); + expect(first.toBlockHeight).toBe("0"); + expect(store.getWatcherCursor()).toBe("0"); + expect(errors).toHaveLength(1); + + const second = await watcher.runOnce(); + expect(second.disputeErrors).toBe(0); + expect(second.disputesSubmitted).toBe(1); + expect(second.toBlockHeight).toBe("124"); + expect(store.getWatcherCursor()).toBe("124"); + + watcher.stop(); + store.close(); + }); + + it("keeps event cursor and reports list source failures", async () => { + const dbFile = tempDbFile("agent-event-source-error"); + const store = new AgentStateStore({ dbFile }); + + const errors: Error[] = []; + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => { + throw new Error("indexer timeout"); + }, + onError: (error) => { + errors.push(error as Error); + }, + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(false); + expect(result.listErrors).toBe(1); + expect(result.scanned).toBe(0); + expect(result.toBlockHeight).toBe("0"); + expect(store.getWatcherCursor()).toBe("0"); + expect(errors).toHaveLength(1); + + watcher.stop(); + store.close(); + }); + + it("counts invalid closure events without aborting scan", async () => { + const dbFile = tempDbFile("agent-event-invalid-event"); + 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 agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + disputeCalls += 1; + return { txid: "0xdispute-ok" }; + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + disputeOnlyBeneficial: true, + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + listClosureEvents: async () => [ + { + eventName: "invalid", + }, + { + contractId, + pipeKey, + eventName: "force-close", + nonce: "5", + closer: "ST1OTHER", + txid: "0xtx-valid", + blockHeight: "123", + expiresAt: "200", + closureMyBalance: "20", + }, + ], + }); + + const result = await watcher.runOnce(); + expect(result.ok).toBe(true); + expect(result.invalidEvents).toBe(1); + expect(result.scanned).toBe(1); + expect(result.disputesSubmitted).toBe(1); + expect(result.toBlockHeight).toBe("123"); + expect(store.getWatcherCursor()).toBe("123"); + expect(disputeCalls).toBe(1); + + watcher.stop(); + store.close(); + }); + + it("skips overlapping readonly watcher runs", async () => { + const dbFile = tempDbFile("agent-readonly-overlap"); + 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, + }); + + let resolveFetch: ((value: unknown) => void) | null = null; + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0xnoop" }; + }, + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async callContract() { + return { ok: true }; + }, + }, + network: "devnet", + }); + + const watcher = new HourlyClosureWatcher({ + agentService: agent, + getPipeState: () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + }); + + const firstRunPromise = watcher.runOnce(); + const overlapping = await watcher.runOnce(); + + expect(overlapping.ok).toBe(true); + expect(overlapping.skipped).toBe(true); + expect(overlapping.reason).toBe("already-running"); + + resolveFetch?.({ + "balance-1": "20", + "balance-2": "80", + "expires-at": "200", + nonce: "5", + closer: "ST1OTHER", + }); + + await firstRunPromise; + 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("rejects incoming transfer requests with token mismatch", () => { + const dbFile = tempDbFile("agent-sign-token-mismatch"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const pipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: "ST1TOKEN.token-1", + }; + const pipeId = buildPipeId({ contractId, pipeKey }); + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey, + localPrincipal: "ST1LOCAL", + counterpartyPrincipal: "ST1OTHER", + token: "ST1TOKEN.token-1", + }); + + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: "ST1TOKEN.token-2", + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("token-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with actor mismatch", () => { + const dbFile = tempDbFile("agent-sign-actor-mismatch"); + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1THIRD", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("actor-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with pipe id mismatch", () => { + const dbFile = tempDbFile("agent-sign-pipeid-mismatch"); + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + pipeId: "wrong-pipe-id", + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("pipe-id-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with non-sequential nonce", () => { + const dbFile = tempDbFile("agent-sign-nonce-gap"); + 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: "1", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "92", + theirBalance: "8", + nonce: "3", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("nonce-not-sequential"); + store.close(); + }); + + it("rejects incoming transfer requests that change total pipe balance", () => { + const dbFile = tempDbFile("agent-sign-balance-sum"); + 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: "1", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "95", + theirBalance: "10", + nonce: "2", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("balance-sum-mismatch"); + store.close(); + }); + + it("rejects incoming transfer requests with invalid counterparty balance direction", () => { + const dbFile = tempDbFile("agent-sign-balance-direction"); + 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: "1", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "85", + theirBalance: "15", + nonce: "2", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("balance-direction-invalid"); + store.close(); + }); + + it("rejects incoming transfer requests with pipe key mismatch", () => { + const dbFile = tempDbFile("agent-sign-pipekey-mismatch"); + const store = new AgentStateStore({ dbFile }); + const contractId = "ST1TESTABC.contract"; + const trackedPipeKey = { + "principal-1": "ST1LOCAL", + "principal-2": "ST1OTHER", + token: null, + }; + const pipeId = buildPipeId({ contractId, pipeKey: trackedPipeKey }); + + store.upsertTrackedPipe({ + pipeId, + contractId, + pipeKey: trackedPipeKey, + 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 validation = agent.validateIncomingTransfer({ + pipeId, + payload: { + contractId, + pipeKey: { + "principal-1": "ST1LOCAL", + "principal-2": "ST1THIRD", + token: null, + }, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "90", + theirBalance: "10", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "22".repeat(65), + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.reason).toBe("pipe-key-mismatch"); + store.close(); + }); + + it("opens a pipe via signer adapter with expected contract call", async () => { + const dbFile = tempDbFile("agent-open"); + const store = new AgentStateStore({ dbFile }); + + const calls: Array<{ contractId: string; functionName: string; functionArgs: unknown[]; network?: string }> = []; + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract(input) { + calls.push(input as { contractId: string; functionName: string; functionArgs: unknown[]; network?: string }); + return { ok: true, txid: "0xopen" }; + }, + }, + network: "devnet", + }); + + const result = await agent.openPipe({ + contractId: "ST1STACKFLOW.stackflow-0-6-0", + token: null, + amount: "1000", + counterpartyPrincipal: "ST1COUNTERPARTY", + nonce: "0", + }); + + expect(result.ok).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + contractId: "ST1STACKFLOW.stackflow-0-6-0", + functionName: "fund-pipe", + functionArgs: [null, "1000", "ST1COUNTERPARTY", "0"], + network: "devnet", + }); + + store.close(); + }); + + it("builds outgoing transfer from tracked state and accepts signed incoming update", async () => { + const dbFile = tempDbFile("agent-send-receive"); + 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: "50", + theirBalance: "50", + nonce: "0", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + }); + + const outgoing = agent.buildOutgoingTransfer({ + pipeId, + amount: "25", + }); + + expect(outgoing.actor).toBe("ST1LOCAL"); + expect(outgoing.myBalance).toBe("25"); + expect(outgoing.theirBalance).toBe("75"); + expect(outgoing.nonce).toBe("1"); + + const accepted = await agent.acceptIncomingTransfer({ + pipeId, + payload: { + contractId, + pipeKey, + forPrincipal: "ST1LOCAL", + withPrincipal: "ST1OTHER", + token: null, + myBalance: "75", + theirBalance: "25", + nonce: "1", + action: "1", + actor: "ST1OTHER", + theirSignature: "0x" + "33".repeat(65), + }, + }); + + expect(accepted.accepted).toBe(true); + expect(accepted.mySignature).toMatch(/^0x[0-9a-f]+$/); + + const latest = store.getLatestSignatureState(pipeId, "ST1LOCAL"); + expect(latest?.nonce).toBe("1"); + expect(latest?.myBalance).toBe("75"); + expect(latest?.theirBalance).toBe("25"); + + store.close(); + }); + + it("rejects outgoing transfer requests with actor mismatch", () => { + const dbFile = tempDbFile("agent-send-actor-mismatch"); + 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: "100", + theirBalance: "0", + nonce: "0", + action: "1", + actor: "ST1LOCAL", + mySignature: "0x" + "11".repeat(65), + theirSignature: "0x" + "22".repeat(65), + secret: null, + validAfter: null, + beneficialOnly: false, + }); + + const agent = new StackflowAgentService({ + stateStore: store, + signer: { + async sip018Sign() { + return "0x" + "44".repeat(65); + }, + async submitDispute() { + return { txid: "0x1" }; + }, + async callContract() { + return { ok: true }; + }, + }, + }); + + expect(() => + agent.buildOutgoingTransfer({ + pipeId, + amount: "25", + actor: "ST1THIRD", + }), + ).toThrow("actor must match tracked local principal"); + + 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/stackflow-aibtc-adapter.test.ts b/tests/stackflow-aibtc-adapter.test.ts new file mode 100644 index 0000000..8c4efc4 --- /dev/null +++ b/tests/stackflow-aibtc-adapter.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { AibtcWalletAdapter } from "../packages/stackflow-agent/src/aibtc-adapter.js"; + +describe("AibtcWalletAdapter.sip018Sign", () => { + it("uses domain shape by default for modern MCP providers", async () => { + const calls: Array<{ name: string; args: any }> = []; + const adapter = new AibtcWalletAdapter({ + invokeTool: async (name, args) => { + calls.push({ name, args }); + return { signature: "abcd" }; + }, + }); + + const sig = await adapter.sip018Sign({ + contract: "SP1ABC.stackflow-sbtc-0-6-0", + message: { hello: "world" }, + }); + + expect(sig).toBe("abcd"); + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("sip018_sign"); + expect(calls[0].args.domain).toEqual({ + name: "stackflow-sbtc-0-6-0", + version: "0.6.0", + }); + expect(calls[0].args.contract).toBeUndefined(); + }); + + it("falls back to legacy contract shape when domain shape is rejected", async () => { + const calls: Array<{ name: string; args: any }> = []; + const adapter = new AibtcWalletAdapter({ + invokeTool: async (name, args) => { + calls.push({ name, args }); + if (calls.length === 1) { + throw new Error("Invalid arguments: unknown field domain"); + } + return { signature: "ef01" }; + }, + }); + + const sig = await adapter.sip018Sign({ + contract: "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0", + message: { hello: "world" }, + }); + + expect(sig).toBe("ef01"); + expect(calls).toHaveLength(2); + expect(calls[0].args.domain).toEqual({ + name: "stackflow-sbtc-0-6-0", + version: "0.6.0", + }); + expect(calls[1].args.contract).toBe( + "SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0", + ); + }); +}); diff --git a/tests/stackflow-node-config.test.ts b/tests/stackflow-node-config.test.ts new file mode 100644 index 0000000..689c08a --- /dev/null +++ b/tests/stackflow-node-config.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { loadConfig } from '../server/src/config.ts'; + +const P1 = 'ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5'; +const P2 = 'ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG'; + +describe('stackflow-node config parsing', () => { + it('loads sane defaults when env is empty', () => { + const config = loadConfig({}); + + expect(config.host).toBe('127.0.0.1'); + expect(config.port).toBe(8787); + expect(config.maxRecentEvents).toBe(500); + expect(config.stacksNetwork).toBe('devnet'); + expect(config.signatureVerifierMode).toBe('readonly'); + expect(config.disputeExecutorMode).toBe('auto'); + expect(config.forwardingTimeoutMs).toBe(10_000); + expect(config.forwardingRevealRetryIntervalMs).toBe(15_000); + expect(config.forwardingRevealRetryMaxAttempts).toBe(20); + expect(config.dbFile).toContain('server/data/stackflow-node-state.db'); + }); + + it('normalizes and de-duplicates watched principals', () => { + const config = loadConfig({ + STACKFLOW_NODE_PRINCIPALS: ` ${P1},${P2},${P1} `, + }); + + expect(config.watchedPrincipals).toEqual([P1, P2]); + }); + + it('rejects invalid watched principal values', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_PRINCIPALS: 'not-a-principal', + }), + ).toThrow(); + }); + + it('rejects watched principal lists above max size', () => { + const manyPrincipals = Array.from({ length: 101 }, (_, index) => + index % 2 === 0 ? P1 : P2, + ).join(','); + + expect(() => + loadConfig({ + STACKFLOW_NODE_PRINCIPALS: manyPrincipals, + }), + ).toThrow(/exceeds max of 100/); + }); + + it('clamps and coerces numeric safety bounds', () => { + const config = loadConfig({ + STACKFLOW_NODE_MAX_RECENT_EVENTS: '-10', + STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE: '-1', + STACKFLOW_NODE_FORWARDING_MIN_FEE: '-99', + STACKFLOW_NODE_FORWARDING_TIMEOUT_MS: '25', + STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_INTERVAL_MS: '10', + STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTS: '0', + }); + + expect(config.maxRecentEvents).toBe(1); + expect(config.peerWriteRateLimitPerMinute).toBe(0); + expect(config.forwardingMinFee).toBe('0'); + expect(config.forwardingTimeoutMs).toBe(1_000); + expect(config.forwardingRevealRetryIntervalMs).toBe(1_000); + expect(config.forwardingRevealRetryMaxAttempts).toBe(1); + }); + + it('parses boolean aliases and rejects invalid boolean text', () => { + const config = loadConfig({ + STACKFLOW_NODE_FORWARDING_ENABLED: 'YeS', + STACKFLOW_NODE_TRUST_PROXY: '0', + }); + + expect(config.forwardingEnabled).toBe(true); + expect(config.trustProxy).toBe(false); + + expect(() => + loadConfig({ + STACKFLOW_NODE_FORWARDING_ENABLED: 'maybe', + }), + ).toThrow(/STACKFLOW_NODE_FORWARDING_ENABLED must be a boolean/); + }); + + it('fails fast when integer env vars contain non-integer text', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_FORWARDING_TIMEOUT_MS: '30s', + }), + ).toThrow(/STACKFLOW_NODE_FORWARDING_TIMEOUT_MS must be an integer/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE: '12.5', + }), + ).toThrow(/STACKFLOW_NODE_PEER_WRITE_RATE_LIMIT_PER_MINUTE must be an integer/); + }); + + it('fails fast when integer env vars exceed safe integer bounds', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_MAX_RECENT_EVENTS: '9007199254740992', + }), + ).toThrow(/STACKFLOW_NODE_MAX_RECENT_EVENTS must be a safe integer/); + }); + + it('rejects stackflow-node ports outside the TCP range', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_PORT: '0', + }), + ).toThrow(/PORT must be an integer between 1 and 65535/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_PORT: '70000', + }), + ).toThrow(/PORT must be an integer between 1 and 65535/); + }); + + it('normalizes forwarding base-url allowlists', () => { + const config = loadConfig({ + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: + ' https://node-b.example.com/path?x=1 , http://127.0.0.1:9797/ ', + }); + + expect(config.forwardingAllowedBaseUrls).toEqual([ + 'https://node-b.example.com', + 'http://127.0.0.1:9797', + ]); + }); + + it('rejects forwarding base-url allowlist entries that are not http/https', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_FORWARDING_ALLOWED_BASE_URLS: + 'https://node-b.example.com,ftp://bad.example.com', + }), + ).toThrow(/must use http\/https/); + }); + + it('supports strict mode validation for enum and message version fields', () => { + expect(() => + loadConfig({ + STACKFLOW_NODE_SIGNATURE_VERIFIER_MODE: 'bad-mode', + }), + ).toThrow(/SIGNATURE_VERIFIER_MODE/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE: 'bad-mode', + }), + ).toThrow(/DISPUTE_EXECUTOR_MODE/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE: 'bad-mode', + }), + ).toThrow(/COUNTERPARTY_SIGNER_MODE/); + + expect(() => + loadConfig({ + STACKS_NETWORK: 'dev', + }), + ).toThrow(/STACKS_NETWORK must be one of/); + + expect(() => + loadConfig({ + STACKFLOW_NODE_STACKFLOW_MESSAGE_VERSION: '版本', + }), + ).toThrow(/must be ASCII/); + }); +}); diff --git a/tests/stackflow-node-dispute.test.ts b/tests/stackflow-node-dispute.test.ts new file mode 100644 index 0000000..d22f29a --- /dev/null +++ b/tests/stackflow-node-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, + StackflowNode, +} from '../server/src/stackflow-node.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 stackflowNode = new StackflowNode({ + stateStore: store, + watchedPrincipals: [P2], + }); + + await expect( + stackflowNode.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(stackflowNode.status().signatureStates).toHaveLength(0); + + cleanupDb(store, dbFile); + }); + + it('rejects invalid signatures before storing state', async () => { + const { store, dbFile } = makeStore(); + const stackflowNode = new StackflowNode({ + stateStore: store, + signatureVerifier: new RejectingSignatureVerifier(), + }); + + await expect( + stackflowNode.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(stackflowNode.status().signatureStates).toHaveLength(0); + + cleanupDb(store, dbFile); + }); + + it('stores only the latest signature state by nonce', async () => { + const { store, dbFile } = makeStore(); + const stackflowNode = new StackflowNode({ stateStore: store }); + + const first = await stackflowNode.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 stackflowNode.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(stackflowNode.status().signatureStates).toHaveLength(1); + expect(stackflowNode.status().signatureStates[0].nonce).toBe('5'); + + const third = await stackflowNode.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(stackflowNode.status().signatureStates).toHaveLength(1); + expect(stackflowNode.status().signatureStates[0].myBalance).toBe('700'); + expect(stackflowNode.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 stackflowNode = new StackflowNode({ + stateStore: store, + disputeExecutor: executor, + }); + + await stackflowNode.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 stackflowNode.ingest(payload, '/new_block'); + expect(executor.calls).toHaveLength(1); + expect(executor.calls[0].forPrincipal).toBe(P1); + + await stackflowNode.ingest(payload, '/new_block'); + expect(executor.calls).toHaveLength(1); + + const attempts = stackflowNode.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 stackflowNode = new StackflowNode({ + stateStore: store, + disputeExecutor: executor, + }); + + await stackflowNode.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 stackflowNode.ingest( + forceCancelPayload({ + txid: '0xforce2', + sender: P2, + nonce: 8, + balance1: 500, + balance2: 500, + blockHeight: 300, + }), + '/new_block', + ); + + expect(executor.calls).toHaveLength(0); + expect(stackflowNode.status().disputeAttempts).toHaveLength(0); + + cleanupDb(store, dbFile); + }); +}); 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/stackflow-node-observer.test.ts b/tests/stackflow-node-observer.test.ts new file mode 100644 index 0000000..9c6b798 --- /dev/null +++ b/tests/stackflow-node-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/stackflow-node-state.test.ts b/tests/stackflow-node-state.test.ts new file mode 100644 index 0000000..82dffb7 --- /dev/null +++ b/tests/stackflow-node-state.test.ts @@ -0,0 +1,418 @@ +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 { 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(); + for (const suffix of ['', '-wal', '-shm']) { + const target = `${dbFile}${suffix}`; + if (fs.existsSync(target)) { + fs.unlinkSync(target); + } + } +} + +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, + 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: CONTRACT_ID, + 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 stackflowNode = new StackflowNode({ + stateStore: store, + watchedPrincipals: [P1], + }); + + const result = await stackflowNode.ingest(payloadFor('force-close', P2, P3), '/new_block'); + + expect(result.observedEvents).toBe(0); + expect(stackflowNode.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 stackflowNode = new StackflowNode({ stateStore: store }); + + await stackflowNode.ingest(payloadFor('force-close'), '/new_block'); + + 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 stackflowNode.ingest(payloadFor('finalize'), '/new_block'); + + status = stackflowNode.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 stackflowNode = new StackflowNode({ stateStore: store }); + await stackflowNode.ingest(payloadFor('fund-pipe'), '/new_block'); + + const 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'); + 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 stackflowNode = new StackflowNode({ stateStore: store }); + await stackflowNode.ingest(payloadFor('force-close'), '/new_block'); + + 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 stackflowNode.ingest(payloadFor('dispute-closure'), '/new_block'); + + status = stackflowNode.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 stackflowNode = new StackflowNode({ stateStore: store }); + await stackflowNode.ingest(payloadFor('fund-pipe'), '/new_block'); + + 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 stackflowNode.ingest(payloadFor('close-pipe'), '/new_block'); + + status = stackflowNode.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 stackflowNode = new StackflowNode({ 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: `${CONTRACT_ID}|${pipeId}`, + pipeId, + contractId: CONTRACT_ID, + 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 stackflowNode.ingestBurnBlock(158, '/new_burn_block'); + expect(before.settledPipes).toBe(0); + + 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 stackflowNode.ingestBurnBlock(159, '/new_burn_block'); + expect(after.settledPipes).toBe(1); + + status = stackflowNode.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); + }); + + 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/stackflow.test.ts b/tests/stackflow.test.ts index 8f750b0..ed5780c 100644 --- a/tests/stackflow.test.ts +++ b/tests/stackflow.test.ts @@ -2,250 +2,29 @@ import { Cl, ClarityType, ClarityValue, - createStacksPrivateKey, - cvToString, ResponseOkCV, - serializeCV, - signWithKey, - StacksPrivateKey, } 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 { beforeEach, describe, expect, it } from "vitest"; +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 +61,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,81 +71,31 @@ 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)); }); }); 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)); }); }); @@ -438,7 +167,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 +284,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 +345,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 +439,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); @@ -749,6 +478,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); @@ -778,7 +596,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 +828,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 +956,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 +1393,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 +1465,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 +1537,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 +1698,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 +1951,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 +2027,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 +2103,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 +2185,149 @@ 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", () => { + // 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(StackflowError.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(StackflowError.InvalidTotalBalance)); }); }); @@ -2433,7 +2393,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", () => { @@ -2502,7 +2462,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); @@ -2829,7 +2789,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); @@ -2858,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", @@ -2942,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); @@ -3026,7 +2978,7 @@ describe("agent-dispute-closure", () => { const { result } = simnet.callPublicFn( "stackflow", - "agent-dispute-closure", + "dispute-closure-for", [ Cl.principal(address1), Cl.none(), @@ -3043,7 +2995,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", () => { @@ -3104,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(), @@ -3121,7 +3073,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); @@ -3228,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(), @@ -3342,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(), @@ -3458,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(), @@ -3475,7 +3427,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); @@ -3533,7 +3485,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", () => { @@ -3568,7 +3520,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); @@ -3678,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); @@ -3896,7 +3906,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); @@ -3924,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", () => { @@ -4285,7 +4333,7 @@ describe("deposit", () => { ], address1 ); - expect(result1).toBeErr(Cl.uint(TxError.NoSuchPipe)); + expect(result1).toBeErr(Cl.uint(StackflowError.NoSuchPipe)); simnet.callPublicFn( "stackflow", @@ -4319,7 +4367,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", () => { @@ -4387,7 +4435,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")!; @@ -4519,7 +4567,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")!; @@ -4612,7 +4660,7 @@ describe("deposit", () => { address1 ); - expect(result).toBeErr(Cl.uint(TxError.InvalidBalances)); + expect(result).toBeErr(Cl.uint(StackflowError.InvalidBalances)); }); }); @@ -4819,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); @@ -4885,7 +5027,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")!; @@ -4990,7 +5132,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")!; @@ -5095,7 +5237,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")!; @@ -5249,7 +5391,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")!; @@ -5435,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); @@ -5498,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(), @@ -5519,7 +5661,7 @@ describe("agent-dispute additional tests", () => { address3 ); - expect(result).toBeErr(Cl.uint(TxError.Unauthorized)); + expect(result).toBeErr(Cl.uint(StackflowError.NonceTooLow)); }); }); @@ -5729,7 +5871,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", () => { @@ -5784,7 +5926,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")!; @@ -5957,7 +6099,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); @@ -6239,7 +6381,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); @@ -6277,3 +6419,500 @@ 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)) + ); + }); +}); + +// `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)); + }); +}); + +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/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..0288032 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,265 @@ +import { + Cl, + ClarityValue, + serializeCV, + serializeCVBytes, + signWithKey, +} from "@stacks/transactions"; +import { sha256 as nobleSha256 } from "@noble/hashes/sha256"; + +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 address4 = accounts.get("wallet_4")!; +export const stackflowContract = `${deployer}.stackflow`; +export const reservoirContract = `${deployer}.reservoir`; + +export const deployerPK = + "753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601"; +export const address1PK = + "7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801"; +export const address2PK = + "530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101"; +export const address3PK = + "d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901"; +export const address4PK = + "f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701"; + +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, + 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, + InvalidSignature = 125, + InvalidFee = 204, +} + +export enum ReservoirError { + BorrowFeePaymentFailed = 200, + Unauthorized = 201, + FundingFailed = 202, + TransferFailed = 203, + InvalidFee = 204, + AlreadyInitialized = 205, + NotInitialized = 206, + UnapprovedToken = 207, + IncorrectStackflow = 208, + AmountNotAvailable = 209, +} + +const structuredDataPrefix = Buffer.from([0x53, 0x49, 0x50, 0x30, 0x31, 0x38]); + +const chainIds = { + mainnet: 1, + testnet: 2147483648, +}; + +export function sha256(data: Buffer): Buffer { + return Buffer.from(nobleSha256(data)); +} + +function structuredDataHash(structuredData: ClarityValue): Buffer { + return sha256(Buffer.from(serializeCVBytes(structuredData))); +} + +const domainHash = structuredDataHash( + Cl.tuple({ + name: Cl.stringAscii(stackflowContract), + 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])); +} + +export function signStructuredData( + privateKey: string, + structuredData: ClarityValue +): Buffer { + const hash = structuredDataHashWithPrefix(structuredData); + const data = signWithKey(privateKey, hash.toString("hex")); + return Buffer.from(data.slice(2) + data.slice(0, 2), "hex"); +} + +export function generatePipeSignature( + privateKey: string, + 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: string, + 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: string, + 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: string, + 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: string, + 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 + ); +} 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/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", 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"] +} 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. 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: [], + }, +});