Build / Filters

Filters

One way to describe a chain event, projected onto whichever surface you send it to. Write the filter once; ask it for the shape Index, Streams, Subscriptions, or Subgraphs expects.

import { on } from "@secondlayer/stacks/filters";

const sbtcMints = on.ftMint({
  assetIdentifier: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
  minAmount: 100_000n,
});

await sl.index.events.list(sbtcMints.toIndexParams({ limit: 50 }));
await sl.streams.events.list(sbtcMints.toStreamsParams());
await sl.subscriptions.create({ trigger: sbtcMints.toChainTrigger(), url });

Because the four surfaces genuinely disagree, and a spread hides that:

  • Index spells the print event print; Subgraphs spell it print_event.
  • contract_call and contract_deploy live on a different Index endpoint than events do.
  • Subscriptions take amounts as strings — a bigint in a JSON.stringify body throws at runtime, not at compile time.
  • trait scoping exists on Index and Subgraphs, not on Streams.

Each projection converts explicitly. toChainTrigger() is where 100_000n becomes "100000" — the bug that a structural spread ships to production.

A filter type that a surface does not support simply lacks that method:

on.sbtcDeposit({}).toChainTrigger();   // ✓ trigger-only event
on.sbtcDeposit({}).toIndexParams();    // ✗ Property 'toIndexParams' does not exist

"Property does not exist" names the problem; a never parameter does not. Field gating comes second — trait is absent from toStreamsParams(), wildcards are Subscriptions/Subgraphs-only.

on.ftTransfer({ assetIdentifier: "SP…token" });        // ✗ throws — that's a contract id
on.ftTransfer({ assetIdentifier: "SP…token::token" }); // ✓

A contract id where an asset identifier belongs is the most common copy-paste mistake in this API, and it used to produce a query that quietly returned zero rows. The factory now validates principals and asset identifiers as it builds the filter, so you get a throw at construction naming the field.

Construction-time, not compile-time

The Principal brand exists in @secondlayer/stacks/filters, but the filter fields are still typed string — validation runs when the filter is built, not when it is typed. A wrong literal fails fast and loudly; it is not yet a red squiggle.

toSubgraphSource() and fromSubgraphSource() round-trip, so a source you already wrote in defineSubgraph can be lifted into a filter and back without losing its abi literal — which is what keeps event.input typed inside the handler.

import { fromSubgraphSource } from "@secondlayer/stacks/filters";

const source = fromSubgraphSource(subgraph.sources.swaps);
await sl.index.events.list(source.toIndexParams({ limit: 10 }));

The round-trip is a CI gate: every production subgraph's sources must survive toSubgraphSource(fromSubgraphSource(f)) === f, and every filter's toChainTrigger() must satisfy the Subscriptions schema.