Skip to content

Repository files navigation

streamfall

Exact second-by-second insolvency cascades for networks of continuous money streams, computed from events instead of ticks.

The problem

In streaming-payment systems (Superfluid-style constant flow agreements, payroll streams, vesting contracts) every account's balance is a linear function of time. When a sender runs dry its outgoing streams are closed, its recipients' net rates drop, and they may run dry too. The usual way to answer "who breaks first, and when?" is to step a simulation one second at a time. That is slow over months of seconds. It also silently picks an order when two liquidations land in the same second, even though that order can decide who gets liquidated and who survives.

streamfall computes the exact cascade (every liquidation second, every unit of bad debt, every final balance) in time proportional to the number of liquidations, not the horizon. It replays every same-second tie in all orders and reports the ties whose outcome depends on the order.

How it works

Model. Balances are arbitrary-precision integers (bigint). A stream moves rate units per second. Opening it locks a deposit (default rate × 3600) out of the sender's wallet. At whole second t an account is critical if its balance is <= 0 and its net rate is < 0. Liquidating it writes any negative balance off as bad debt, closes all its outgoing streams, and pays their deposits to a designated liquidator account. After every liquidation, this identity holds:

sum(balances) + deposits held = initial supply + bad debt

Lazy linear accounts. Each account stores (balance at t0, t0, net rate). Its balance at any later second is balance + rate × (t − t0). It is written back ("settled") only when its rate changes. An account with a negative rate is due at t0 + ceil(balance / −rate). That second is its key in a binary min-heap. When a liquidation changes an account's rate, the old heap entry is invalidated by bumping a per-account version counter and a fresh entry is pushed (lazy deletion). Every pop is either stale or a real event, so work is O((liquidations + closed streams) · log accounts), however long the horizon.

Tie groups. All accounts due in the same second form a tie group. Before the group is applied in canonical order (ascending account index), the solver replays it in every permutation (Heap's algorithm, up to 6 accounts = 720 orders, then a seeded sample of 200 orders). Each replay writes into an undo journal that is rolled back afterwards. A replay re-checks criticality before each liquidation, because a deposit paid to a liquidator in the same group can lift it back above zero. The resulting states of the affected accounts are fingerprinted, and a group with more than one distinct fingerprint is reported as order-dependent.

Worked example (examples/keeper-tie.json): fund and keeper each have 10 free units after a 300-unit deposit and stream 3/s to vendor, and keeper is the liquidator. At t=4 both balances are −2:

order fund > keeper:  fund liquidated, its 300 deposit lifts keeper to 298, keeper keeps streaming until t=104
order keeper > fund:  keeper liquidated (paying itself), then fund liquidated: both stop at t=4

Verification. src/ticksim.ts is an independent per-second simulator. It advances every balance every second and rescans every account. The test suite generates 2,000 seeded networks (chains, fans, cycles, random and mixed graphs, tuned so balances often hit zero on the same second) and requires the two engines to agree exactly on the liquidation timeline, final balances, bad debt and deposits, and the identity above must hold after every event. When the solver says every tie in a network is order-independent, the reference simulator is rerun with shuffled tie order and must still agree. Each shard of 500 seeds also asserts that it actually contains multi-step cascades, ties and order-dependent ties, so the comparison is not vacuous.

Install and usage

Requires Node.js 22.12 or later.

From a checkout of this repository:

npm ci
npm test
npm run build        # compiles to dist/; the CLI is dist/src/bin.js

A graph is JSON. Amounts may be numbers or integer strings (use strings beyond 2^53). deposit defaults to rate × bufferSeconds (bufferSeconds defaults to 3600). If liquidator is omitted, a passive account named liquidator is added.

{
  "horizon": 86400,
  "liquidator": "keeper",
  "accounts": [
    { "id": "fund", "balance": "310" },
    { "id": "keeper", "balance": "310" },
    { "id": "vendor", "balance": "0" }
  ],
  "streams": [
    { "from": "fund", "to": "vendor", "rate": 3, "deposit": 300 },
    { "from": "keeper", "to": "vendor", "rate": 3, "deposit": 300 }
  ]
}

Solve it:

$ node dist/src/bin.js solve examples/keeper-tie.json
3 accounts, 2 streams, horizon 86400s
t=4  fund liquidated: 1 stream(s) closed, deposits 300 to keeper, bad debt 2
t=104  keeper liquidated: 1 stream(s) closed, deposits 300 to keeper, bad debt 2
tie at t=4: {fund, keeper}, all 2 orders, ORDER-DEPENDENT, 2 distinct outcomes
  order fund > keeper: liquidated {fund}, bad debt fund=2
  order keeper > fund: liquidated {fund, keeper}, bad debt fund=2 keeper=2
total bad debt 4; deposits still held 0
final balances: fund=0 keeper=300 vendor=324
events: 3 heap pops (0 stale), 2 rounds
$ echo $?
3

The exit status is 0 for a clean run, 3 if any tie was order-dependent, 1 for an invalid graph and 2 for bad usage. --json prints the same report as JSON with amounts as decimal strings. --max-exhaustive N, --sampled-orders N and --seed N control the tie exploration.

Check a graph against the per-second reference simulator (a 30-day payroll graph):

$ node dist/src/bin.js check examples/payroll.json
event solver 0.36 ms, per-second simulator 325.61 ms over 2592000s: identical (3 liquidations)

Generate a seeded random network and pipe it in (- reads stdin):

$ node dist/src/bin.js generate --seed 3 --accounts 3 --horizon 600 | node dist/src/bin.js solve -
4 accounts, 5 streams, horizon 600s
no liquidations before the horizon
total bad debt 0; deposits still held 70
final balances: a0=1045 a1=610 a2=879 liquidator=0
events: 0 heap pops (0 stale), 0 rounds

As a library, parseNetwork(json) validates a graph and solve(net, options) returns liquidations, finalBalances, badDebt, depositsHeld, tieGroups, orderDependent and stats.

Results

npm run bench generates seeded "mixed" networks (rates up to 1000/s, deposits up to 30 s of flow), runs both engines, fails if their results differ, and prints the median of three seeds. Measured on an Apple M2 (8 cores, one used), macOS, Node 24.12.0:

accounts streams horizon (s) liquidations event solver (ms) per-second (ms) speed-up
50 100 86400 18 0.13 43 340x
50 100 2592000 18 0.06 1273 22051x
500 1000 86400 237 7.53 466 62x
2000 5000 86400 753 54.05 2222 41x

The event solver alone, over one year (31,536,000 s), where the per-second simulator is impractical:

accounts streams horizon (s) liquidations tie groups heap pops event solver (ms)
10000 30000 31536000 2739 10 4531 196.1
100000 300000 31536000 31947 63 39714 3714.2

The event solver's cost does not grow with the horizon (a 30× longer horizon in rows 1–2 costs nothing extra). The per-second simulator grows linearly with it. The speed-up shrinks as networks get denser with liquidations, because then there is more real work per second. Timings of a few milliseconds or less vary by tens of percent between runs.

Design notes

The central decision is to treat simultaneity as something to report, not something to hide. A tick simulator must pick an order within a second, and whichever it picks is an arbitrary protocol rule presented as a fact. streamfall still applies one deterministic canonical order, so its timeline is reproducible and comparable against the reference simulator. For every tie it also replays the alternatives, and it tells you when the choice mattered. Replaying is cheap because it is local: a tie only touches the tied accounts, their stream recipients and the liquidator. The undo journal records the first prior state of each touched account, so rolling back costs as much as the replay did. Replaying every order is factorial, so above six accounts the solver samples orders from a seeded generator. That keeps worst-case cost bounded, but a sampled group can miss an order-dependent outcome, and the report marks those groups as not exhaustive.

The second decision is exact integer time and money. Balances are bigint, and critical times are integer ceiling divisions. There is no floating-point root finding, so "fails at t=22 with bad debt 4" is exact and can be checked bit-for-bit against the per-second simulator. The cost is bigint allocation and arithmetic on the hot path, which is slower than doubles would be. The payoff is that the 2,000-network differential test can demand exact equality instead of a tolerance that could hide an off-by-one-second bug.

Limitations

  • Rates are constant between liquidations. The model has no scheduled stream starts or stops, rate changes, top-ups or external transfers during the horizon. It computes the cascade that follows from a fixed starting graph.
  • The liquidation rule is fixed: an account is liquidated when its balance is <= 0 with a negative net rate, on whole seconds only; all outgoing streams close; deposits go to one liquidator; negative balances become bad debt. There are no partial liquidations, no per-stream liquidators, no time-based liquidator reward schemes and no liquidation delay.
  • Tie outcomes are compared on the state right after the tied second (balances, rates and bad debt of the affected accounts). Two orders that differ at that second but converge later are still reported as order-dependent, as in the keeper example above. The solver follows only the canonical order to the horizon; it does not compute the full downstream timeline of each alternative.
  • Groups larger than --max-exhaustive (default 6) are sampled, not proven order-independent.
  • It is a single-threaded in-memory solver. 100,000 accounts and 300,000 streams take about 3.7 s on an M2; much larger graphs have not been measured.

License

MIT. See LICENSE.

About

Exact second-by-second insolvency cascades for networks of continuous money streams, with no ticking

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages