stroopwatch
v0.1.2
Published
A watchtower for Stellar payment channels: settles what you earned before the buyer's refund window closes.
Maintainers
Readme
stroopwatch.
A watchtower for Stellar payment channels. Settles what you earned before the buyer's refund window closes.
Install · Quick start · CLI · Library · Architecture · Trust model
On a one-way payment channel a buyer pays you off-chain: a signature per request, no transaction, no confirmation. You settle once, at the end. That is what makes the channel worth using.
It also means the buyer can call close_start, wait out a window, and call
refund, which returns the entire remaining balance, including revenue you
earned but have not settled. The contract expects the recipient to notice and
answer in time. The SDK detects a dispute while a voucher is being verified.
Nothing ships that answers one.
stroopwatch is that answer. One process beside your server, watching the ledger, holding your latest signed commitment, submitting the claim with time to spare.
| the same attack, twice | withdrawn | | :--- | ---: | | nobody watching | 0 | | stroopwatch running | 6,000,000 |
Reproducible on testnet: npm run demo:robbed and npm run demo:defended.
Install
npm i stroopwatchNode 20 or newer. One runtime dependency (@stellar/stellar-sdk), no build step,
no native modules.
Quick start
# 1 · before you accept a channel, check the window you are being offered
npx stroopwatch preflight --channel CCW3A4… --minLedgers 60
# 2 · each time the buyer pays you off-chain, save the signature
npx stroopwatch record --channel CCW3A4… --amount 6000000 --sig 5b56f0…
# 3 · leave the watchman running in its own process, not inside your server
STROOPWATCH_SECRET=S… npx stroopwatch watch --channel CCW3A4…watching CCW3A4…GEPW · commitment on file: 6000000 stroops
⚠ DISPUTE · refund effective at ledger 3992753
dispute: claiming 6000000 stroops by ledger 3992751
SETTLED · 6000000 stroops secured · tx 1719c931…CLI
stroopwatch watch
Runs the keeper. Pass several ids separated by commas to watch more than one channel, five per process at most — see Known limits.
| flag | default | |
| :--- | :--- | :--- |
| --channel <id[,id…]> | required | channels to watch |
| --secret <S…> | $STROOPWATCH_SECRET | the recipient key close() authorizes |
| --store <path> | .stroopwatch/commitments.json | commitment store |
| --margin <ledgers> | 2 | headroom before the refund becomes effective |
| --pollMs <ms> | 2000 | ledger poll interval |
| --rpcUrl <url> | Stellar testnet RPC | |
A dispute stays queued and is retried on every poll until it settles or the window genuinely passes, so a single RPC flake does not cost you the money.
stroopwatch preflight
Reads a channel and judges its refund window. Exits non-zero when the window is too short, so it belongs in CI, or in the code path that decides whether to accept a channel at all.
channel CA2P6W…GHTO
window 12 ledgers (~60s)
✗ TOO SHORT · leaves no room to respond; require at least 60 (~300s)stroopwatch record
Stores a signed cumulative commitment. The store is monotonic: a lower or replayed total never overwrites a higher one, and malformed signatures are rejected rather than shadowing valid ones.
Library
Every piece is importable if you would rather run the watching and the settling inside your own supervisor.
import { CommitmentStore, Checkpoint, ChannelWatcher, Keeper } from 'stroopwatch'
const store = new CommitmentStore('.stroopwatch/commitments.json')
const checkpoint = new Checkpoint('.stroopwatch/cursor.json')
const keeper = new Keeper({ rpcUrl, signer: secret, store, margin: 2 })
new ChannelWatcher({
rpcUrl,
channels: [channelId],
checkpoint,
onDispute: (d) => keeper.respond(d), // return truthy once settled
}).start()| export | |
| :--- | :--- |
| CommitmentStore | atomic, monotonic, fails loud on corruption |
| Checkpoint | durable ledger cursor, so a restart resumes from before the event |
| ChannelWatcher | independent RPC poller; retries a dispute until it lands |
| Keeper | builds, signs, submits and confirms close() |
| preflight | the window check, as a function |
| prepareCommitment | the signing payload, built locally |
| decodeCloseEvent | the deadline, out of a raw contract event |
Returning a falsy value from onDispute keeps the dispute queued for the next
poll.
Architecture
flowchart LR
B["buyer<br/>close_start"] --> CH["channel contract"]
CH -->|"event::Close<br/>{ effective_at_ledger }"| W["ChannelWatcher<br/>own process, own RPC poll"]
W <-->|"resume point"| P["Checkpoint<br/>ledger cursor, on disk"]
W -->|"dispute"| K["Keeper"]
C["CommitmentStore<br/>atomic, monotonic, on disk"] -->|"highest"| K
K -->|"close(amount, sig)<br/>at effective - margin"| CHThe full set of diagrams, including the restart path and the dispute state machine, is in the repository README.
Three decisions carry the design.
It does not use the SDK's watcher. @stellar/mpp ships watchChannel() and
an onDisputeDetected callback, but that callback is only reached while a
voucher is being verified. A buyer who calls close_start and then stops
sending traffic never triggers it. The poller also lives inside the seller's
process, so it dies with the seller's deploy. stroopwatch polls the ledger from
its own process and keeps its cursor on disk.
The deadline comes from the event. close_effective_at_ledger is private on
the contract, so the window cannot be read back with a contract call. The Close
event carries it, which is precisely what the contract tells recipients to
monitor.
The commitment payload is built locally. Calling the contract's
prepare_commitment would be a network round trip per request. src/commitment.js
builds the same bytes offline; the struct is keyed by symbol, not string, and
the output is checked byte-for-byte against the deployed contract in the tests.
What the contract asks of you
The channel contract states the recipient's responsibilities in its own source
(contracts/channel/src/lib.rs:29-38):
- Verifies the
refund_waiting_periodat channel creation is long enough to allow them to react to a close_start event.- Verifies the
amountin each commitment is less than the channels balance.- Monitors the channel for
event::Closeevents.- Calls
settlewith a commitment promptly after seeing a close_start event, before the funder callsrefund.
| the contract asks the recipient to… | stroopwatch |
| :--- | :--- |
| verify the refund window is long enough | stroopwatch preflight |
| verify each commitment is below the balance | not covered. That is the seller's own accounting, before the voucher is accepted |
| monitor the channel for Close events | ChannelWatcher |
| settle promptly, before refund | Keeper, submitting at effective − margin |
Note that the funder chooses refund_waiting_period, and neither the channel
constructor nor the factory enforces a floor on it.
Configuration
Read from stroopwatch.config.json in the working directory; every field can
also be passed as a flag.
{
"rpcUrl": "https://soroban-testnet.stellar.org",
"store": ".stroopwatch/commitments.json",
"margin": 2,
"pollMs": 2000,
"minLedgers": 60
}The signing key comes from STROOPWATCH_SECRET rather than the file, so the
config can be committed.
Trust model
close() begins with to.require_auth(). Unlike a Lightning watchtower, which
holds a blinded, pre-signed justice transaction and learns nothing, a Stellar
keeper cannot be blind. It needs signing authority for the recipient, and today
that means it holds the recipient key. The trust has been moved, not removed.
The next step is a policy-scoped Soroban smart account that narrows the keeper to
exactly one power: may call close on this channel, for no more than the
highest commitment it has seen; may never settle elsewhere; may never move
funds. That is designed and not built, and it is the honest limit of the package
today.
Known limits
Two operational limits, documented here rather than discovered:
- A checkpoint older than the RPC's retention window is fatal, and the watcher
does not yet say so. Soroban RPC retains a bounded range of ledgers. Resume
from a cursor older than that and
getEventsfails on every poll; the retry loop treats the error as transient and keeps trying. Delete the cursor file and restart to resume from now, accepting that whatever landed in the gap is beyond the RPC's reach. - Five channels per process. Soroban RPC allows at most 5 contract ids per
getEventsfilter, sowatchrefuses a sixth at startup rather than polling into silent errors forever. Run one process per group of five.
Demos
Each script deploys its own channel with accounts you control. None of them touch a public deployment.
| | |
| :--- | :--- |
| npm run demo:robbed | seller runs nothing → withdrawn: 0 |
| npm run demo:defended | keeper answers in ~4s → withdrawn: 6000000 |
| npm run demo:restart | the seller's process is killed before the attack |
| npm run demo:crash | stroopwatch itself is killed before the attack, then restarted cold |
| npm run demo:commerce | a metered API sold over x402 and over a channel, then attacked |
demo:crash is the one worth running: the keeper is killed before the buyer
attacks, so the dispute is emitted with nothing alive to see it. The restarted
process reads its cursor off disk, finds the dispute it was never present for,
and answers it.
resuming from ledger 3992731
⚠ DISPUTE · refund effective at ledger 3992753
SETTLED · 6000000 stroops secured · tx 08a06a89…These need stellar-cli and the channel contract built from
stellar-experimental/one-way-channel.
The web app runs the same attack from the browser with no toolchain at all: it
deploys the contract with the JavaScript SDK.
Repository
This package lives in a workspace alongside the things that exercise it.
packages/stroopwatch/ the published package, this directory
apps/web/ the site: the argument, a live demo, a channel check
examples/metered-api/ a price-tick API sold over x402 and over a channel
scripts/ the testnet demosThe root is private and cannot be published. Everything imports stroopwatch by
name, so there is one implementation of the bytes the contract verifies rather
than a copy per consumer.
Tests
npm test # 35 tests, no networkCovering the properties the keeper's correctness rests on: the commitment store
is monotonic, crash-safe and fails loud; the event decoder handles the deadline
across Address-vs-string contract ids, base64 XDR and a second close_start that
resets the window; a dispute is only cleared once the handler reports success,
and one the keeper already won reports its receipt instead of "giving up"; the
ledger cursor survives a restart and never jumps past a truncated event page;
and the locally built commitment matches the contract byte-for-byte.
CI runs the suite on Node 20, 22 and 24, and installs the packed tarball into a clean project to check the CLI works outside this repo.
Prior art
Watchtowers are settled elsewhere: LND has shipped one since 2019,
rust-teos is the BOLT-13 reference
implementation, and Raiden specified a Monitoring Service that submits the latest
balance proof when a partner closes. A mature channel protocol without a
watchtower is an unfinished one; MPP channels shipped in 2026.
Not to be confused with the OpenZeppelin Stellar Channels Service, which pools classic Stellar channel accounts for sequence-number parallelism. Different thing, unlucky name.
License
Apache-2.0
