Stackflow is a payment channel network built on Stacks that enables off-chain, non-custodial, high-speed payments between users and is designed to be simple, secure, and efficient. It supports payments in STX or SIP-010 fungible tokens.
Note
The Stackflow trait is published by stackflow.btc on mainnet at
SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-token-0-5-0 and on testnet at
ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-token-0-5-0.
Note
The official Stackflow contract for STX is published by stackflow.btc on
mainnet at SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-0-6-0 and
on testnet at ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-0-6-0.
Contracts for other tokens should copy this contract exactly to ensure that
all Stackflow frontends will support it.
Note
The official Stackflow contract for sBTC is published by stackflow.btc on
mainnet at SP126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT6AD08RV.stackflow-sbtc-0-6-0
and on testnet at
ST126XFZQ3ZHYM6Q6KAQZMMJSDY91A8BTT59ZTE2J.stackflow-sbtc-0-6-0.
A Stacks address can open a Pipe with any other Stacks address or open a Tap to the Reservoir by calling the Stackflow contract. In a Pipe, tokens are sent into escrow and locked in the contract, and can only be withdrawn by the Pipe participants. Alternatively, by opening a Tap, a user deposits tokens directly into the contract where their balance is tracked on a shared ledger (the Reservoir). Users can then send payments off-chain using either direct Pipes or via the Reservoir.
In both cases, off-chain signed messages are exchanged to update balances. These messages include a nonce to track their ordering. SIP-018 structured data 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 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 uncooperative or unresponsive, the Pipe can also be forcefully closed by the other party. One party can initiate a waiting period to cancel the Pipe and refund the latest balances recorded in the contract, or they can submit signatures verifying an agreed upon balance. In either case, the counterparty has 144 Bitcoin blocks (~1 day) to rebut the submitted balances by providing a newer set of signed messages. If the closure is successfully disputed with valid signatures, those balances are immediately paid out and the Pipe is closed. If the deadline passes with no dispute, a final contract call triggers the payout to both parties.
Pipes can be chained together to enable payments between users who do not have a direct Pipe open between them. This is done by routing the payment through intermediaries who have Pipes open with both the sender and receiver. Off-chain, tools can find the most efficient path to route the payment. Intermediary nodes can charge a fee for routing payments. In the case of chained Pipes, the signed messages passed around include a requirement for a secret to unlock the funds. This ensures that if any hop in the chain fails to send the tokens as promised, the whole transaction can be reverted, allowing the sender and receiver to be sure that the payment goes through completely or they get their tokens back, even with untrusted intermediaries.
Example:
- Alice opens a Pipe with Bob.
- Bob opens a Pipe with Charlie.
- Alice wants to send 10 STX to Charlie.
- Alice sends a signed message to Bob indicating that she wants to send 10 STX to Charlie and is willing to pay Bob 0.1 STX for routing the payment. Alice's signature transferring 10.1 STX to Bob is invalid unless Bob can provide the secret, for which a hash was included in the structured data signed by Alice.
- Bob sends a signed message to Charlie indicating that he wants to send him 10 STX. Charlie is able to decrypt the secret and sends it back to Bob and the chain is complete.
In the above scenario, Bob cannot obtain the secret unless he sends the payment to Charlie, so he is unable to keep the payment from Alice without sending the payment to Charlie. Details of how this works are discussed below.
To send tokens between two parties with an active Pipe open in the contract, they simply send signatures back and forth off-chain. The signatures are signing over structured data with the details of the Pipe:
{
;; The token for which the Pipe is open
token: (optional principal),
;; The first principal, ordered by the principals' consensus serialization
principal-1: principal,
;; The second principal, ordered by the principals' consensus serialization
principal-2: principal,
;; The balance of principal-1
balance-1: uint,
;; The balance of principal-2
balance-2: uint,
;; A monotonically increasing identifier for this action
nonce: uint,
;; The type of action (u0 = close, u1 = transfer, u2 = deposit, u3 = withdraw)
action: uint,
;; The principal initiating the action, `none` for close
actor: (optional principal),
;; An optional sha256 hash of a secret, used for multi-hop transfers or
;; other advanced functionality
hashed-secret: (optional (buff 32))
;; An optional Bitcoin block height at which this transfer becomes valid. If
;; set, this transfer cannot be used in `force-close` or `dispute-closure`
;; to validate balances until the specified block height has passed.
valid-after: (optional uint)
}In this direct transfer scenario, the hashed-secret is not needed, and can be
set to none.
When A wants to send to B, A builds this data structure with the adjusted balances and the appropriate nonce, signs over it, and then sends these in a message to B. B validates the signature, and verifies that the new balances match an incoming transfer. If everything is correct, then B responds to A with a signature of its own. Both parties (or a representative thereof) should record these new signatures and balances. Future transactions should build from these balances, and the signatures may be needed to dispute if something goes wrong later.
When principal A wants to send tokens to principal D using StackFlow, first, a path from A to D is identified using existing open Pipes with sufficient balances, for example using Dijkstra's algorithm. If a path is not possible, then a new Pipe must be opened. Once a path is defined, A builds the transfer message as described above, with the addition of a secret.
The next part of the transfer message describes what the receiver must do in
order to get the Pipe balance confirmed. For the final hop in the chain, the
target receiver of the payment will have no next hop, since at that point, the
payment is complete. For all other hops in the chain, the receiver of the
message uses the next hop to determine how to build its transfer message,
defining which principal to send to, receiver, how much to send, amount, how
much should be leftover to keep for itself as a fee, fee, an encrypted secret,
secret.
type Hop = {
receiver: string;
amount: bigint;
fee: bigint;
secret: Uint8Array;
nextHop: Number;
};The initial sender constructs an array of 8 of these Hop objects, encrypting
each with the public key of the intended target. If less than 8 hops are needed,
then random data is filled into the remaining slots. The sender then sends this
array, along with its signature to the first hop in the chain, along with the
index for that hop. That first hop decrypts the value at the specified hop and
uses the information within to send a signature and the array to the next hop,
along with the index for that next hop.
For the final hop in the chain, D in our example, the receiver is set to
D itself and the amount is set to 0. This is the signal to D that it
is the final destination for this payment, so it can reply back to the previous
hop with the decrypted secret. Each hop validates, then passes this decrypted
secret back to its predecessor, eventually reaching the source and validating
the completed payment.
There are two ways to close a Pipe, cooperatively or forcefully. In the normal
scenario, both parties agree to close the Pipe and authorize the closure by
signing a message agreeing upon the final balances. One party calls
close-pipe, passing in these balances and signatures, and then the contract
pays out both parties and the Pipe is closed.
In the unfortunate scenario where one party becomes uncooperative or unresponsive, the other party needs a way to retrieve their funds from the Pipe unilaterally. This user has two options:
force-cancelallows the user to close the Pipe and refund the initial balances to both parties. This option requires no signatures, but it does require a waiting period, allowing the other party to dispute the closure by submitting signatures validating a transfer on this Pipe.force-closeallows the user to close the Pipe and return the balances recorded in the latest transfer on this Pipe. Arguments to this function include signatures from the latest transfer, along with the agreed upon balances at that transfer. This function also requires a waiting period, allowing the other party to dispute the closure by submitting a later pair of signatures, indicating a more recent balance agreement (one with a higher nonce).
During the waiting period for both of these closures, the other party may call
dispute-closure, passing in balances and signatures from the latest transfer.
If the signatures are confirmed, and the nonce is higher in the case of a
force-close, then the Pipe is immediately closed, transferring with the
balances specified to both parties.
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.
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:
npm run stackflow-nodeOptional environment variables:
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=<private-key-used-to-submit-disputes>
STACKFLOW_NODE_COUNTERPARTY_KEY=<private-key-used-to-sign-off-chain-states>
STACKFLOW_NODE_COUNTERPARTY_PRINCIPAL=<optional principal to sign for, e.g. ST... or ST....contract>
STACKFLOW_NODE_COUNTERPARTY_SIGNER_MODE=local-key
STACKFLOW_NODE_COUNTERPARTY_KMS_KEY_ID=<aws-kms-key-id-or-arn>
STACKFLOW_NODE_COUNTERPARTY_KMS_REGION=<aws-region>
STACKFLOW_NODE_COUNTERPARTY_KMS_ENDPOINT=<optional custom endpoint, e.g. localstack>
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=<optional bearer/admin token for sensitive reads>
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=20If STACKFLOW_CONTRACTS is omitted, the stackflow-node automatically monitors any
contract identifier matching *.stackflow*.
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.
Observer ingress controls:
-
STACKFLOW_NODE_OBSERVER_LOCALHOST_ONLYdefaults totrueand restrictsPOST /new_blockandPOST /new_burn_blockto 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 (viaAuthorization: Bearer ...orx-stackflow-admin-token) forGET /signature-statesandGET /forwarding/payments. -
STACKFLOW_NODE_ADMIN_READ_LOCALHOST_ONLYdefaults totrue; when no admin token is configured, sensitive reads are limited to localhost sources. -
STACKFLOW_NODE_REDACT_SENSITIVE_READ_DATAdefaults totrue; without an admin token, signatures and revealed secrets are redacted in response bodies.STACKFLOW_NODE_FORWARDING_ENABLEDenables routed transfer forwarding support.STACKFLOW_NODE_FORWARDING_MIN_FEEsets the minimum forwarding spread:incomingAmount - outgoingAmount.STACKFLOW_NODE_FORWARDING_TIMEOUT_MScontrols timeout for next-hop signing calls.STACKFLOW_NODE_FORWARDING_ALLOW_PRIVATE_DESTINATIONSdefaults tofalse; whenfalse, 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_MScontrols how often pending upstream reveal propagation retries are attempted.STACKFLOW_NODE_FORWARDING_REVEAL_RETRY_MAX_ATTEMPTSsets the maximum reveal propagation attempts before a payment is markedfailed. IfSTACKFLOW_NODE_PRINCIPALSis set, the stackflow-node only:
- accepts
POST /signature-statesforforPrincipalvalues in that list - processes closure events for pipes that include at least one listed principal
When STACKFLOW_NODE_PRINCIPALS is omitted:
- if counterparty signing is configured (
STACKFLOW_NODE_COUNTERPARTY_KEYor KMS), it watches only the derived counterparty principal - otherwise, it accepts any principal
Health and inspection endpoints:
GET /healthGET /closuresGET /pipes?limit=100&principal=ST...GET /signature-states?limit=100GET /dispute-attempts?limit=100GET /events?limit=100GET /app(built-in browser UI)GET /forwarding/payments?limit=100GET /forwarding/payments?paymentId=<id>
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:
- terminate TLS at ingress (or run end-to-end TLS/mTLS)
- require authn/authz for external callers at ingress
- restrict observer ingress (
STACKFLOW_NODE_OBSERVER_ALLOWED_IPSand/or localhost-only) - set
STACKFLOW_NODE_ADMIN_READ_TOKENfor sensitive read endpoints - keep
STACKFLOW_NODE_TRUST_PROXY=falseunless behind a trusted proxy chain
Counterparty signing endpoints:
POST /counterparty/transferPOST /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:
- require peer-protocol headers:
x-stackflow-protocol-version: 1x-stackflow-request-id: <8-128 chars [a-zA-Z0-9._:-]>idempotency-key: <8-128 chars [a-zA-Z0-9._:-]>
- enforce idempotency per endpoint:
- same
idempotency-key+ same payload replays the original response - same
idempotency-key+ different payload returns409(idempotency-key-reused) - idempotency records are retention-pruned (24h TTL and capped history)
- same
- 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
- verify the counterparty signature (
verify-signature-request) - generate the counterparty signature
- store the full signature pair via the existing signature-state pipeline
Responses from counterparty endpoints include:
protocolVersionrequestId(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:
POST /forwarding/transferPOST /forwarding/reveal
/forwarding/transfer coordinates a routed transfer by:
- requesting a downstream signature from the configured next hop (
outgoing) - signing the upstream state locally (
incoming) - enforcing
incomingAmount - outgoingAmount >= STACKFLOW_NODE_FORWARDING_MIN_FEE - requiring a 32-byte
hashedSecretfor lock/reveal tracking - persisting forwarding payment outcomes in SQLite
- retaining only the latest nonce record per
(contractId, pipeId)in forwarding history
Request body shape:
{
"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:
{
"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:
revealPropagationStatus = pending|propagated|failed|not-applicablerevealPropagationAttemptsincrements on each upstream attemptrevealNextRetryAtschedules the next retry timestamp for pending records- background retries resume automatically on process restart
Forwarding retention notes:
- forwarding payment history keeps only the latest nonce entry per pipe
- older nonce entries are pruned once newer nonce data is stored for that pipe
- 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:
POST /signature-states
Example payload:
{
"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:
POST /new_blockprint events update a persistentpipesview- events such as
fund-pipe,deposit,withdraw,force-cancel, andforce-closeupsert current pipe balances - terminal events (
close-pipe,dispute-closure,finalize) reset tracked balances to0and clear pending values POST /new_burn_blockadvances pending deposits into confirmed balances once pendingburn-heightis reachedGET /pipesmerges 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:
POST /new_blockPOST /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:
POST /new_mempool_txPOST /drop_mempool_txPOST /new_microblocks
For Clarinet devnet, set the observer in settings/Devnet.toml:
[devnet]
stacks_node_events_observers = ["host.docker.internal:8787"]Open the built-in UI in your browser:
http://localhost:8787/app
The UI lets you:
- connect a Stacks wallet
- load watched pipes from on-chain observer state (
GET /pipes) - generate SIP-018 structured signatures for Stackflow state
- submit signature states to
POST /signature-states - call Stackflow contract methods (
fund-pipe,deposit,withdraw,force-cancel,force-close,close-pipe,finalize) via wallet popup
This repo includes a starter x402-style gateway at
server/src/x402-gateway.ts. It sits in front of your app server and:
- protects one route (
STACKFLOW_X402_PROTECTED_PATH, default/paid-content) - returns
402when no payment proof is provided - supports two payment modes:
direct: verifies payment immediately viaPOST /counterparty/transferindirect: waits for forwarded payment arrival, checks who paid, then verifies the provided secret viaPOST /forwarding/reveal
- proxies the request to your upstream app only after verification succeeds
Detailed technical design and production guidance:
server/X402_GATEWAY_DESIGN.mdserver/X402_CLIENT_SDK_DESIGN.mdpackages/x402-client/(SDK scaffold with SQLite-backed client state store)server/STACKFLOW_AGENT_DESIGN.md(simple agent runtime without local node)
Run it with:
npm run x402-gatewayRun a full local end-to-end demo (unpaid + direct + indirect):
npm run demo:x402-e2eThe demo script starts:
- stackflow-node (
accept-allverifier, forwarding enabled) - x402 gateway
- mock upstream app
- mock forwarding next-hop
Then it automatically exercises:
- unpaid request ->
402 - direct payment proof -> payload delivered
- indirect payment signal (wait for forwarding payment + reveal) -> payload delivered
Run an interactive browser demo (click link -> 402 -> sign -> unlock):
npm run demo:x402-browserBrowser demo flow:
- open the printed local URL (gateway front door)
- click
Read premium story - gateway returns
402 Payment Required - demo queries
stackflow-node /pipesfor pipe status:- if no open pipe, prompt
Open Pipe(walletfund-pipe) first - if pipe is observer-confirmed and spendable, prompt
Sign and Pay(stx_signStructuredMessage)
- if no open pipe, prompt
- browser retries with
x-x402-payment - gateway verifies payment with stackflow-node and returns paywalled content
Notes about the browser demo:
- Network/contract selection is loaded from
demo/x402-browser/config.json(orDEMO_X402_CONFIG_FILE) rather than editable in the browser UI. - The demo starts stackflow-node in
accept-allsignature verification mode for local UX walkthrough. - Pipe readiness checks are routed through stackflow-node (
GET /pipes) via demo endpoints (/demo/pipe-status). Open Pipedoes not fake state. It waits for real observer updates from stacks-node into stackflow-node.- If the connected wallet is the same principal as the server counterparty,
the demo returns a clear
409and asks you to switch accounts. - 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"]matchingstacksNodeEventsObserverin that file. - Browser-demo starts stackflow-node with
STACKFLOW_NODE_DISPUTE_EXECUTOR_MODE=auto. It usesDEMO_X402_DISPUTE_SIGNER_KEYif provided. Ondevnet, if unset, it defaults to Clarinetwallet_1fixture key. On other networks, if unset, it reusesDEMO_X402_COUNTERPARTY_KEY. - Child process logs are streamed by default (
DEMO_X402_SHOW_CHILD_LOGS=true). SetDEMO_X402_SHOW_CHILD_LOGS=falseto silence stackflow-node/gateway logs.
Example browser demo config:
{
"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:
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=<optional token for GET /forwarding/payments>The client supplies x-x402-payment containing JSON (or base64url-encoded JSON)
for one of these modes:
directpayment proof (action = 1) including:
contractIdforPrincipalwithPrincipalamount(must be>= STACKFLOW_X402_PRICE_AMOUNT)myBalancetheirBalancetheirSignaturenonceactor
indirectpayment 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):
# 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-contentExample flow (indirect):
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-contentCurrent scaffold scope:
- supports direct and indirect receive-side verification
- one-time proof consumption per method/path within replay TTL
- single protected route configuration (expand to route policy map as next step)
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:
- SQLite persistence for tracked pipes and latest signatures
- AIBTC MCP wallet adapter hooks for
sip018_sign,call_contract, and read-onlyget-pipe - hourly closure watcher (default
60 * 60 * 1000) that polls tracked pipes via read-onlyget-pipeand can auto-submit disputes when a newer beneficial local signature state exists
See:
packages/stackflow-agent/README.mdserver/STACKFLOW_AGENT_DESIGN.md
Integration tests for the HTTP server are opt-in (they spawn a real process and bind a local port):
npm run test:stackflow-node:httpAs discussed in the details, users of Stackflow should run a server to keep track of balances and signatures for its open Pipes, and to receive messages from its counterparts in those Pipes. A reference implementation of this server is provided in https://github.com/obycode/stackflow-server. This server consists of the backend for managing these details, as well as a frontend to provide a simple interface for opening Pipes and interacting with existing Pipes.