npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

stroopwatch

v0.1.2

Published

A watchtower for Stellar payment channels: settles what you earned before the buyer's refund window closes.

Readme

stroopwatch.

A watchtower for Stellar payment channels. Settles what you earned before the buyer's refund window closes.

ci npm node license

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 stroopwatch

Node 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"| CH

The 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_period at channel creation is long enough to allow them to react to a close_start event.
  • Verifies the amount in each commitment is less than the channels balance.
  • Monitors the channel for event::Close events.
  • Calls settle with a commitment promptly after seeing a close_start event, before the funder calls refund.

| 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 getEvents fails 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 getEvents filter, so watch refuses 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 demos

The 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 network

Covering 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