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 itprint_event. contract_callandcontract_deploylive on a different Index endpoint than events do.- Subscriptions take amounts as strings — a
bigintin aJSON.stringifybody throws at runtime, not at compile time. traitscoping 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.