# Early Solana product stream — contract

ULTRA, BUSINESS and ENTERPRISE stream tokens advertise this endpoint and its channels
through `POST /api/v1/stream/token` (`early_ws_url`, `early_stream.channels`). Only
advertised channels are served. Observations are provisional instruction intent for
supported outer instructions; execution outcomes arrive separately.

## Connection and subscription

Target endpoint: `wss://madeonsol.com/ws/v1/early`.
Use the existing token returned by `POST /api/v1/stream/token`, preferably in
`Authorization: Bearer <stream-token>`. `?token=` is supported; conflicting header
and query values are rejected. This endpoint does not accept an `msk_` API key
directly. The token route advertises `early_ws_url` and the served channel list to
ULTRA, BUSINESS and ENTERPRISE only (server flags `EARLY_STREAM_MODE=on`,
`SNIPER_SOURCE=shredprism`). `early:deploys` is live since 2026-10-06 21:45 UTC;
the other five families since 2026-10-08 06:03 UTC. The token route's OpenAPI
schema, `GET /api/v1/asyncapi.json` and the TypeScript SDK (madeonsol-x402 4.1.0+)
describe this contract.

ULTRA, BUSINESS and ENTERPRISE require an active, unexpired subscription. Tokens
retain their existing explicit-rotation grace of 60 seconds. Authorization is
refreshed every five seconds and expires locally after at most fifteen seconds;
expired/revoked/downgraded rights or failed reauthorization stop delivery. There
is no per-event DB lookup and no entitlement bypass for synthetic monitor users.
Connection limits are 3 for ULTRA and 10 for BUSINESS/ENTERPRISE on this service.

```json
{"type":"subscribe","channels":["early:deploys"],"filters":{"launchpads":["pumpfun","launchlab"]}}
```

No event is sent before subscription. Omit filters for all implemented outer
instruction variants; this is not a claim of all-market coverage. Allowed filters: `mints`,
`actors` (or `wallets`), `launchpads`, `protocols`, `actions`, `wallet_labels`, `directions`, each a nonempty array when supplied (maximum 1,000 entries). `amounts` instead accepts up to four typed rules described below.
Filter dimensions combine with AND; values within a dimension combine with OR.
Unknown filters and unimplemented channels are refused, not silently ignored.

`sub_id` optionally names an independent subscription (1–64 ASCII letters,
digits, `_`, `.`, `-`). The existing subscription caps apply: 10 ULTRA and
20 BUSINESS/ENTERPRISE, including `default`. Supplied `update.filters` replaces its filters;
omitting it preserves the current filters, and explicit `{}` clears them. This
prevents a format-only update from broadening a wallet subscription. Previously
omitting filters on update cleared them; clients must now send `{}` to clear them.
`format` is also preserved when omitted;
`unsubscribe` removes the entire named/default subscription; `list` returns them.
A repeated subscribe replaces that subscription's channel set. Each matching
subscription receives its own copy; named copies include `sub_id`.

## Precise trade and amount filters

`directions: ["buy"]` or `["sell"]` narrows a trade-only subscription. Supplying
this filter with another channel is rejected. `amounts` is an AND list of one to
four rules. Each rule requires `field`, `mint`, and at least one of `min_raw` or
`max_raw`. Bounds are inclusive canonical decimal u64 strings (0 through
18446744073709551615), with no leading zeroes except "0". Numeric JSON values,
negative/scientific/overflow values and unknown rule keys are rejected atomically.

```json
{
  "type": "subscribe",
  "sub_id": "wallet-buys",
  "channels": ["early:trades"],
  "filters": {
    "wallets": ["WALLET_ADDRESS"],
    "directions": ["buy"],
    "amounts": [{
      "field": "requested_input_raw",
      "mint": "So11111111111111111111111111111111111111112",
      "min_raw": "1000000000"
    }]
  }
}
```

This selects supported exact-input buys whose requested input is at least 1 SOL
in raw WSOL/SOL units; it does not establish an actual fill. A buy encoded with
only `max_input_raw` does not satisfy a `requested_input_raw` rule. The matching
asset must be present and equal the specified mint. Null/missing amounts or assets
never pass. There is no live USD conversion or token-decimals lookup.

| Allowed instruction field | Asset field used for matching |
| --- | --- |
| `requested_amount_raw` | `mint` |
| `requested_input_raw`, `max_input_raw` | `input_mint` |
| `min_output_raw`, `target_output_raw` | `output_mint` |
| `target_lp_output_raw`, `requested_lp_input_raw` | `lp_mint` |
| `max_base_input_raw`, `min_base_output_raw`, `requested_base_input_raw` | `mint` |
| `max_quote_input_raw`, `min_quote_output_raw`, `requested_quote_input_raw` | `quote_mint` |

These names preserve their different meanings: exact requested inputs, spend
ceilings, output floors or requested targets. Multiple rules and other filter
dimensions must all match. The same policy applies to observations, outcomes and
replay. An amount-only subscription can span channels but only events with the
requested field and asset can match.

## Optional compact events

Use `"format":"compact-v1"` in a subscribe/update control. `full` remains the
default. Subscription acknowledgements and `list` include the selected format;
unknown formats are rejected without changing a valid subscription. Explicit
`update.filters: {}` clears filters; changing format alone preserves them.

Compact v1 is **lossless JSON key compaction**, not omitted provenance or a binary
transport. Envelope identity/channel/cursor/time stay unchanged. Observation and
outcome frames add `format: "compact-v1"`; only keys inside `data` change. The frozen
ordered `COMPACT_FIELDS` dictionary in `services/early-stream/client/frames.mjs`
maps index to its lowercase base-36 key (schema_version = "0", chain = "1", etc.).
Unknown field names are encoded as "~" + the original name; nested values, nulls,
exact numeric strings and future fields are preserved. The v1 dictionary must not
be extended or reordered; a different dictionary requires a new version.

`expandFrame` restores the complete full frame and removes the format marker.
It passes control/gap frames through and rejects unknown data formats or duplicate
expanded field names. Even compact subscriptions receive ordinary JSON text WS
frames, with the same authorization and actual serialized-byte backpressure limits.
Replay retains one canonical full frame; compact forms are produced on demand and
are not additional retained history. Named subscriptions keep their `sub_id`.

The local declared corpus saves 17.3% of observed-event bytes, at extra encoding /
client-expansion cost. This is an optional bandwidth trade-off, not a promised
latency improvement. See `docs/audits/early-ultra-options-2026-10-07.md`.

## Token and wallet quickstarts

Portable builders in `services/early-stream/client/subscriptions.mjs` provide:

```js
import { followTokens, walletTrades } from './services/early-stream/client/subscriptions.mjs';
import { expandFrame } from './services/early-stream/client/frames.mjs';

// Send the returned control over an authenticated early WebSocket.
const tokenSubscription = followTokens(['TOKEN_MINT'], { sub_id: 'token', format: 'compact-v1' });
const walletSubscription = walletTrades(['WALLET_ADDRESS'], { sub_id: 'wallet', directions: ['buy'] });
// In the message handler: const frame = expandFrame(JSON.parse(message));
```

`followTokens` subscribes to all six supported channel families with the supplied
mint filter, including primary/quote/input/output matching. It does not discover
unsupported transactions or dynamically follow every new mint. To follow a newly
observed launch, pass its mint to this helper explicitly. Current event identity,
provisional semantics and per-channel order remain unchanged.

A runnable Node example is `services/early-stream/examples/watch.mjs`. Obtain the
existing stream token through your authenticated API client, then supply it via
`EARLY_STREAM_TOKEN` and set exactly one of `EARLY_WALLETS` or `EARLY_MINTS` to a
comma-separated list. Run `node examples/watch.mjs` from the service directory.
It reports observations/outcomes/warnings and closes explicitly on disconnect;
this low-level example intentionally stops at disconnection. A managed client is
available in the TypeScript SDK (madeonsol-x402 4.1.0+); see the SDK section below.

## Six channel families and payload contracts

| Channel | Current outer-instruction coverage |
| --- | --- |
| `early:deploys` | Pump.fun `create` / `create_v2`; Raydium LaunchLab `initialize` / `initialize_v2` / `initialize_with_token_2022` |
| `early:locks` | Streamflow create; Jupiter vesting v1/v2 without nonempty extensions; Bonfida create |
| `early:trades` | Pump.fun / PumpSwap buy, sell and exact-input buy variants |
| `early:liquidity` | PumpSwap create pool, deposit, withdraw |
| `early:migrations` | Pump.fun migrate and migrate_v2 to PumpSwap |
| `early:token_changes` | SPL Token / Token-2022 base mint, burn, checked forms, freeze/thaw and authority types 0–3 |

Every observation uses the common envelope documented in **Frames and semantics**.
The tables below describe the channel-specific keys inside `frame.data`. Unless
stated otherwise, public-key values are base58 strings and raw token/amount
arguments are decimal strings. These are decoded instruction arguments or
accounts, not realized execution state.

### `early:deploys` observation payload

Supported deploy observations carry:

| Field | Meaning |
| --- | --- |
| `action` | Always `"create"`. |
| `protocol` | Not added by the deploy decoder; use `launchpad` + `program_id` / `instruction` for this family. |
| `instruction` | `create`, `create_v2`, `initialize`, `initialize_v2` or `initialize_with_token_2022`. |
| `mint` | Mint account named by the create instruction. |
| `actor` | Instruction user/creator position for the supported layout; it is not a claim about economic ownership. |
| `fee_payer` | Transaction fee payer, kept separate from `actor`. |
| `attribution_basis` | `"instruction_user"`. |
| `launchpad` | `"pumpfun"` or `"launchlab"`. |
| `name`, `symbol` | UTF-8 strings decoded from the supported create arguments; nullable after sanitization. |

Example `data` fragment:

```json
{
  "channel": "early:deploys",
  "action": "create",
  "instruction": "create_v2",
  "mint": "TOKEN_MINT",
  "actor": "INSTRUCTION_USER",
  "fee_payer": "FEE_PAYER",
  "attribution_basis": "instruction_user",
  "launchpad": "pumpfun",
  "name": "Example",
  "symbol": "EX"
}
```

Unknown wrappers, CPI-only creates, unsupported launchpads or unrecognized layouts
do not become a guessed deploy event. They remain explicit coverage gaps/issues.

### `early:trades` observation payload

`early:trades` describes **trade instruction intent**, not a fill. Current
supported actions use `action: "swap_intent"` and add:

| Field | Meaning |
| --- | --- |
| `protocol` | `pumpfun` or `pumpswap`. |
| `instruction` | Supported buy/sell instruction name. |
| `actor` | Instruction user. |
| `attribution_basis` | `"instruction_user"`. |
| `mint`, `quote_mint`, `pool` | Base token, quote token and curve/pool named by the instruction. |
| `direction` | `"buy"` or `"sell"`. |
| `swap_mode` | `"exact_in"` or `"exact_out"`. |
| `input_mint`, `output_mint` | Direction-resolved assets. |
| `requested_input_raw` | Exact-input amount when the instruction is exact-in; otherwise null. |
| `max_input_raw` | Spend ceiling for exact-out buys; otherwise null. |
| `min_output_raw` | Output floor for exact-in instructions; otherwise null. |
| `target_output_raw` | Requested target output for exact-out instructions; otherwise null. |

```json
{
  "channel": "early:trades",
  "protocol": "pumpfun",
  "instruction": "buy_exact_sol_in",
  "action": "swap_intent",
  "actor": "WALLET",
  "mint": "TOKEN_MINT",
  "quote_mint": "So11111111111111111111111111111111111111112",
  "pool": "BONDING_CURVE",
  "direction": "buy",
  "swap_mode": "exact_in",
  "input_mint": "So11111111111111111111111111111111111111112",
  "output_mint": "TOKEN_MINT",
  "requested_input_raw": "1000000000",
  "max_input_raw": null,
  "min_output_raw": "123456789",
  "target_output_raw": null
}
```

Do not derive executed token amounts, price, slippage or PnL from these requested
bounds. Use the confirmed DEX surfaces when you need realized trade data.

### `early:liquidity` observation payload

All current liquidity events are PumpSwap outer instructions and carry
`protocol: "pumpswap"`, an instruction actor and `pool` / `mint` /
`quote_mint` / `lp_mint` where the layout provides them.

| Action | Additional instruction fields |
| --- | --- |
| `create_pool` | `pool_index` (number), `requested_base_input_raw`, `requested_quote_input_raw`, `coin_creator` |
| `add_liquidity` | `target_lp_output_raw`, `max_base_input_raw`, `max_quote_input_raw` |
| `remove_liquidity` | `requested_lp_input_raw`, `min_base_output_raw`, `min_quote_output_raw` |

```json
{
  "channel": "early:liquidity",
  "protocol": "pumpswap",
  "instruction": "deposit",
  "action": "add_liquidity",
  "actor": "WALLET",
  "pool": "POOL",
  "mint": "BASE_MINT",
  "quote_mint": "QUOTE_MINT",
  "lp_mint": "LP_MINT",
  "target_lp_output_raw": "1000000",
  "max_base_input_raw": "250000000",
  "max_quote_input_raw": "1000000000"
}
```

The values are requested targets/ceilings/floors. They are not post-transaction
reserves, realized deposits/withdrawals or USD liquidity.

### `early:migrations` observation payload

Current migration events cover Pump.fun `migrate` / `migrate_v2` instructions:

```json
{
  "channel": "early:migrations",
  "protocol": "pumpfun",
  "instruction": "migrate_v2",
  "action": "migrate",
  "actor": "INSTRUCTION_USER",
  "attribution_basis": "instruction_user",
  "mint": "TOKEN_MINT",
  "quote_mint": "QUOTE_MINT",
  "source_curve": "SOURCE_CURVE",
  "destination_pool": "PUMPSWAP_POOL",
  "launchpad": "pumpfun",
  "destination_venue": "pumpswap"
}
```

This says the supported migration instruction was observed. The later outcome
determines whether the transaction executed; the observation alone does not prove
the destination pool became usable.

### `early:token_changes` observation payload

Current coverage is the base SPL Token and Token-2022 instruction set below.
Extension-only Token-2022 actions are not inferred.

| Action | Fields |
| --- | --- |
| `mint` / `burn` | `actor`, `authority`, `authority_is_signer`, `mint`, `token_account`, `requested_amount_raw`; checked variants also carry `decimals` |
| `freeze` / `thaw` | `actor`, `authority`, `authority_is_signer`, `mint`, `token_account`, `target_scope: "token_account"` |
| `set_authority` | `actor`, `authority`, `authority_is_signer`, `authority_type`, `target_scope`, `target_account`, `mint` or `token_account`, `new_authority`, `authority_revocation_requested`, optional `missing_fields` |

`authority_type` is one of `mint_tokens`, `freeze_account`,
`account_owner` or `close_account`. For token-account authority changes the
instruction itself does not name the mint, so `mint` is null and
`missing_fields` includes `"mint"`.

```json
{
  "channel": "early:token_changes",
  "protocol": "token_2022",
  "instruction": "mint_to_checked",
  "action": "mint",
  "actor": "AUTHORITY",
  "authority": "AUTHORITY",
  "authority_is_signer": true,
  "attribution_basis": "instruction_authority",
  "mint": "TOKEN_MINT",
  "token_account": "DESTINATION_TOKEN_ACCOUNT",
  "requested_amount_raw": "1000000",
  "decimals": 6
}
```

### `early:locks` observation payload

Subscribe to the lock family exactly like the other early families:

```json
{"type":"subscribe","sub_id":"locks","channels":["early:locks"]}
```

A full-format Streamflow observation has this shape (addresses, signature, slot and
times below are illustrative):

```json
{
  "channel": "early:locks",
  "event": "early:observed",
  "id": "solana:SIGNATURE:2:early:locks:create_lock",
  "seq": 123,
  "instance": "INSTANCE",
  "cursor": { "instance": "INSTANCE", "seq": 123 },
  "ts": 1791456000000,
  "data": {
    "schema_version": 1,
    "chain": "solana",
    "signature": "SIGNATURE",
    "event_id": "solana:SIGNATURE:2:early:locks:create_lock",
    "observed_slot": "454500000",
    "first_observed_at": "2026-10-08T10:00:00.000Z",
    "source": "shredprism",
    "source_region": "REGION",
    "observation_stage": "observed",
    "execution_status": "unknown",
    "decoding_basis": "outer_instruction",
    "transaction_version": 0,
    "transaction_config": null,
    "lifetime_token": "BLOCKHASH_OR_LIFETIME",
    "channel": "early:locks",
    "decoder_version": "channels-1",
    "protocol": "streamflow",
    "instruction": "create",
    "outer_instruction_index": 2,
    "program_id": "PROGRAM_ADDRESS",
    "fee_payer": "FEE_PAYER",
    "action": "create_lock",
    "actor": "SENDER",
    "sender": "SENDER",
    "recipient": "RECIPIENT",
    "lock_account": "LOCK_ACCOUNT",
    "mint": "TOKEN_MINT",
    "lock_kind": "stream_or_vesting",
    "attribution_basis": "instruction_sender",
    "requested_amount_raw": "1000000000",
    "schedule": {
      "start_time": "1791456000",
      "period_seconds": "3600",
      "amount_per_period_raw": "100000000",
      "cliff_time": "1791456000",
      "cliff_amount_raw": "0"
    },
    "cancelable_by_sender": true,
    "cancelable_by_recipient": false
  }
}
```

All integer instruction arguments that can exceed JavaScript's safe integer range
are decimal **strings**. Schedule times are encoded Unix-second arguments from the
instruction, also as decimal strings; they are not converted to ISO timestamps.
`requested_amount_raw` is instruction intent, not proof that this amount was
successfully deposited. An `early:locks` observation is therefore not a confirmed
or current lock-state row. Use the later outcome for execution status and
`GET /api/v1/tokens/{mint}/locks` / the confirmed `token:locks` surfaces for
current on-chain lock state.

Protocol-specific lock fields:

| Protocol / instruction | Lock-specific fields |
| --- | --- |
| Streamflow `create` | `sender`, `recipient`, `lock_account`, `mint`, `lock_kind: "stream_or_vesting"`, `requested_amount_raw`, `schedule.start_time`, `period_seconds`, `amount_per_period_raw`, `cliff_time`, `cliff_amount_raw`, `cancelable_by_sender`, `cancelable_by_recipient` |
| Jupiter Lock `create_vesting_escrow` | `sender`, `recipient`, `lock_account`, `sender_token_account`, `lock_kind: "token_vesting"`, `requested_amount_raw`, `schedule.start_time`, `cliff_time`, `period_seconds`, `cliff_amount_raw`, `amount_per_period_raw`, `number_of_periods`, `update_recipient_mode`, `cancel_mode`. V1 does not expose the mint in this instruction, so `mint: null` and `missing_fields` contains `"mint"`. |
| Jupiter Lock `create_vesting_escrow_v2` | Same schedule/mode fields as v1, plus `mint`. The initial decoder accepts only no/empty remaining-account extensions; unsupported extensions fail closed rather than producing a partial lock. |
| Bonfida `create` | `sender`, `lock_account`, `destination_token_account`, `mint`, `lock_kind: "token_vesting"`, aggregate `requested_amount_raw`, and `schedule.releases[]` where every item is `{"release_time":"<unix seconds>","amount_raw":"<base units>"}`. The instruction does not expose the recipient wallet directly, so `recipient: null` and `missing_fields` contains `"recipient"`. |

Common lock fields are `protocol`, `instruction`, `program_id`, `action:
"create_lock"`, `actor`, `sender`, `lock_account`, `lock_kind`,
`attribution_basis`, and the normal ShredPrism provenance fields. A field may be
`null` or listed in `missing_fields` when the supported instruction layout
does not provide it; clients must not infer a value.

The non-deploy outcome policy applies to locks as well: observations are immediate
and provisional; the corresponding `early:outcome` is published only when
finalized, or as unresolved after 120 seconds. Correlate with
`data.observed_event_id`.


This is partial protocol coverage. Raydium, LaunchLab trades, Meteora, Orca,
Jupiter router legs, Moonshot, unknown extensions and CPI-only actions are not
claimed. Protocol-specific real-fixture/release qualification remains open.

```json
{"type":"subscribe","sub_id":"kols","channels":["early:trades"],"filters":{"wallet_labels":["kol"]}}
```

Custom wallets use `filters.wallets: ["WALLET_ADDRESS"]`; they do not depend on
our roster or require a saved watchlist. `actors` is the existing address filter;
supplying both aliases is rejected. `mints` matches the primary, quote, input or
output mint, including exotic quote mints. `launchpads` is restricted to deploys
and migrations. Unknown names/values are rejected.

`wallet_labels` is trade-only and accepts `kol`, `dev`, `alpha`. KOL means active
`kol_wallets`; dev means membership in `deployers`, not proof of economic identity
or being this token's deployer; alpha uses the default public leaderboard criteria
(5+ tokens, nonnegative net PnL, 1+ win, low/no bot confidence). Roster membership
is a database snapshot, not an on-chain instruction fact or a freshness guarantee
for the materialized leaderboard itself. Every non-deploy observation carries a
`wallet_label` object (actor, labels, available label sets, snapshot time and
completeness), even though only `early:trades` accepts the `wallet_labels` filter.

A separate one-connection cold pool refreshes at 60 seconds; snapshots expire at
5 minutes. Each label is replaced only in full and has a row bound (kol and alpha
250,000, dev 2,000,000; all deployers we know of); a larger roster is unavailable,
never a silently truncated roster. Label-filtered
subscriptions are refused with `wallet_labels_unavailable` until all requested
rosters are ready. Expiry reports a gap; custom-wallet/unfiltered delivery continues.
Replay uses the labels captured with the observation and applies the same freshness
bound; historical membership is not recomputed. Private watchlists are never broadcast.

New channels use the bounded replay ring and rolling operational journal. They
are not inserted into the sniper compatibility table or its legacy notifications.
There is no new durable replay/history promise. Their outcomes retain the original
observation fields and labels; actual fills, prices and post-state remain unknown.

## Frames and semantics

Events carry `channel`, `event`, `id`, `seq`, `instance`, `cursor`, `ts` and `data`.
`event` is `early:observed` or `early:outcome`. An observed event's `data` includes
signature, outer instruction index, mint, instruction actor, separate fee payer,
launchpad, source region, observed slot and decoder/version identifiers.
The actor is the instruction user, not a claim of economic developer identity.

`transaction_version` is `"legacy"`, `0` or `1`, independent of the event's
`schema_version`. `lifetime_token` preserves the message's blockhash/lifetime
specifier. For v1, `transaction_config` contains `config_mask`,
`priority_fee_lamports` (decimal string), `compute_unit_limit`,
`loaded_accounts_data_size_limit` and `heap_size` (numeric limits in CU/bytes).
Unset fields remain `null`; explicit zero stays zero. These are encoded requests,
not consumed compute or paid fees. The priority fee is a total in lamports, never
a price per CU. Legacy/v0 have `transaction_config: null`; this does not mean zero
fees or zero compute. Their ComputeBudget instructions are not summarized here.
v1 contains static accounts; v0 ALT positions remain stable during resolution.

Unknown transaction versions, unsupported v1 config bits and invalid framing
reject the entire source batch with a decode gap. There is no byte-scan recovery
into a possibly false next transaction. Syntactically valid entries are not
signature-verified on the hot path and remain unproven execution observations.

`observation_stage: observed` and `execution_status: unknown` describe recognized
intent from leader-produced entries, not successful execution, finality or a
guaranteed head start. The service does not sign or execute transactions.

An outcome has its own id plus `observed_event_id`, `observation_stage: outcome`,
`commitment` (processed/confirmed/finalized), `execution_status` (succeeded/failed)
and the available chain error. These come from Kaldera execution-status evidence,
not corroboration from another ShredPrism region. `outcome_source` names that
evidence: `kaldera_status_stream` for a pushed finalized status (the five
non-deploy channels), `kaldera_rpc` for a status lookup (`early:deploys`, and the
fallback for the other channels), `null` when no evidence was seen. New values may
be added; treat an unknown value as a valid source. Changed evidence has a different
outcome id. A timeout is `resolution_status: unresolved`; it preserves any earlier
execution evidence and does not invent failure or a fork.

Outcome cadence differs by channel; observations never wait for any outcome.
Every observation gets at most one terminal outcome; an `unresolved` outcome has
`execution_status: "unresolved"` unless earlier evidence was seen,
`resolution_status: "unresolved"`, `reason` `finalization_timeout` (evidence
seen, not finalized) or `outcome_timeout` (no evidence), and
`last_observed_outcome`.
`early:deploys` outcomes are progressive: each change of commitment
(processed, confirmed, finalized) is published as it is seen. The other five
channels publish one final outcome per observation: `finalized` with the
execution status, pushed as soon as the chain finalizes the transaction
(typically about 13 to 20 seconds after the observation), or `unresolved` after
120 seconds if no final status was seen, with the last evidence seen if any.
They carry no intermediate processed/confirmed outcome messages and are not
polled on a fixed schedule. A missing status is unknown, never failed.
`early:token_changes` on Token-2022 is covered by the pushed status;
changes on the original SPL Token program are resolved through a strictly
budgeted fallback, so those outcomes can arrive later or end `unresolved` more
often under load. Deploy outcomes are unchanged: they are polled from the start
and published progressively.

Clients must not treat observations as authoritative balances, candles, completed
locks, swap fills, fees or USD valuations. No such execution metadata is fabricated.

## Recovery and overload

Reconnect with the last fully handled cursor:

```json
{"type":"subscribe","channels":["early:deploys"],"resume":{"instance":"INSTANCE_FROM_FRAME","seq":123}}
```

Recovery is currently process-local: 120 seconds, 20,000 frames or 16 MiB,
whichever limit is reached first. A request returns at most 2,000 retained frames.
There is no promised durable replay across restart. Instance changes, retention
loss, source gaps, or limits produce explicit gaps/incomplete recovery.

**Live-first:** current events continue while old frames replay. Old copies have
`replayed: true`, so arrival order during recovery is intentionally nonchronological.
Deduplicate by `(sub_id or default, id)`. Do not advance a completed recovery cursor
from an interleaved live frame: wait for a complete `replay_end.cursor` and reconcile
the live frames already handled. An incomplete replay has no completed cursor.
`replay_start` identifies its high-water mark; future live sequence numbers do not
prove older missing events were recovered. Gap records also make replay incomplete.

**Gap meaning matters.** An outcome-tracker gap such as
`outcome_capacity`, `outcome_byte_capacity`, `outcome_action_capacity` or
`outcome_evidence_capacity` means a **later execution-status follow-up** could
not be retained/polled. It does **not** mean the original `early:observed`
signal was missed: observations are published before outcome tracking. By contrast,
source/decode/worker-queue/lookup/replay gaps can indicate that an observation or
recoverable range was unavailable. Clients should therefore alert separately on
observation-path gaps and outcome-follow-up gaps instead of treating every
`early:gap` as a missing trade/lock/deploy signal.

**Gap frames and scope.** A gap is a top-level frame
`{"event":"early:gap","type":"gap","reason":"…","ts":…,"seq":…,"instance":"…","cursor":{…}}`
plus reason-specific detail such as `slot`, `region`, `label`, `loss` or
`bytes`. It has no `channel` and no `data`, and it is not filtered by mint,
wallet or amount: every connection with at least one subscription receives it.
`scope: "channels"` marks a gap that can affect only the five non-deploy
families (the non-deploy outcome tracker's capacity gaps, or a lookup-table loss
for a transaction with no deploy). A connection subscribed only to
`early:deploys` does not receive those, and a deploy-only resume does not replay
them. A gap without `scope` (or with `scope: "all"`) is global: it can affect
every family, deploys included. Examples: `source_reconnect`, `worker_capacity`,
`queue_overflow`, `queue_stale`, `oversize_batch`, decode errors,
`wallet_labels_unavailable`, and deploy-tracker outcome capacity.
An optional `traffic_class` (`sparse`, `hot` or `deploys`) narrows a gap to
one traffic class (a per-class budget or outcome tracker ran out): it reaches
only connections subscribed to a channel of that class, and a resume replays it
only when the resumed subscription includes one. `scope` keeps its meaning:
`sparse` and `hot` gaps carry `scope: "channels"`, `deploys` gaps
`scope: "all"`, so a client that ignores `traffic_class` over-counts, never
under-counts.

The gateway closes slow consumers with 4008 at its byte/age limits rather than
building an unbounded queue. 4001 indicates authorization is unavailable/expired;
4002 indicates the per-user connection limit. Control-frame rate is capped at
32/second. Filter-index capacity and replay concurrency also refuse explicitly.

## Release evidence

Dated client-receipt benchmarks, with raw results and methodology, are published
at `/solutions/shredprism-stream#benchmarks`. Server write callbacks are not
client receipt. Synthetic local tests and source liveness alone do not prove
market coverage or latency targets; every published number is a dated sample,
not an SLA.

## Existing sniper compatibility routes

Since 2026-10-06 21:45 UTC, `sniper:deploys` / `sniper:deploy` and authenticated
`/api/v1/sniper/recent` / `/api/v1/sniper/by-deployer/{wallet}` are built from the
same action observations. These routes are ULTRA/BUSINESS/ENTERPRISE only, including watchlists
and recovery. PRO no longer receives the old curated sniper alias.

Legacy sniper webhooks re-read the active subscription and current webhook
destination, secret, event subscription and filters before every HTTP attempt,
including retries. An authorization lookup error stops delivery. Sniper WebSocket
replay and queued live frames recheck the connection's current subscription after
socket backpressure clears. A replay interrupted by changed rights reports
`complete: false, reason: entitlement_changed`; it does not count the denied frame
as sent. The existing subscription/token refresh interval still applies to when a
database entitlement change becomes known to an open WebSocket connection.

The direct endpoint publishes first. A separate bounded writer then persists each
action and emits the existing bus notification in the same database statement.
Legacy WebSocket/webhook delivery includes that database/bus delay (25 ms flush
timer plus queue/query time). Use the direct endpoint for the lowest latency.

`event_id` distinguishes multiple creates in one signature and separate attempts
for the same mint. Legacy WebSocket frame ids prefix this with `sniper:deploy:`;
use `data.event_id` to correlate with direct frames and REST. Actor and fee payer
remain separate. REST preserves `transaction_version` and nullable v1 config.
Historical deshred rows remain readable and are labelled `source: deshred`.

ShredPrism rows start with `confirmed_on_chain: null`,
`detection_confirmed: false` and `attribution_status: unverified`.
Only a matching action's separately observed successful confirmed/finalized
execution can set `confirmed_on_chain: true`. This does not verify economic actor
identity, fills or amounts. Unconfirmed/failed early rows have `footprint: null`;
a mint-level rollup cannot prove that particular attempt executed.

Migration 475 stores the cold compatibility copy for a target six hours, pruned
in batches of 5,000 each minute. The existing legacy durable-replay window and
row/time caps still apply; this does not extend `/ws/v1/early`'s process-local
replay. A 4 MiB/2,000-item/30-second queue drops explicitly under overload.
A crash or prolonged DB failure can lose compatibility copies; the direct service
reports capacity/time/shutdown gaps and health counters. Do not infer source
completeness from the presence of a persisted row or a legacy replay completion.

The same release retired `GET /api/x402/sniper/recent`: new requests return
HTTP 410 `x402_endpoint_retired`, with no payment challenge or settlement. Use
`GET /api/v1/sniper/recent` with an ULTRA, BUSINESS or ENTERPRISE API key, or the
direct early WebSocket with an eligible stream token. There is no paid redirect.
The x402 discovery/price tables and landing-page offer no longer list sniper.

With existing paid-result recovery enabled, the original payer can still retrieve
an unexpired, already stored answer using the original payment proof, exact URL
and `PAYMENT-RECOVERY` signature. This returns the original historical bytes; it
never queries fresh sniper rows or generates a deferred answer. A recorded payment
without an answer is explicitly unresolved/missing and requires support review;
clients must not create a replacement payment. No payment, subscription or key is
created by the retirement response. Updated TypeScript/Python SDK sources refuse
keyless sniper calls before requesting data, and MCP requires the API-key route.
Those package changes are not published yet.

Retirement takes effect when this application code is deployed, independently of
`EARLY_STREAM_MODE`. Coordinate it with the ULTRA+ source release. A source-only
rollback does not reopen x402 sales; reopening requires an explicit application
rollback. Validate any in-flight old-version payment attempts during cutover.



## Managed ShredPrism SDK client (madeonsol-x402 4.1.0+)

`MadeOnSolREST.earlyStream()` extends this SDK with the separate early protocol.
It requires an activated `early_ws_url` in `getStreamToken()` discovery and ULTRA+.
The existing confirmed-stream `stream()` contract is unchanged.

```js
import { MadeOnSolREST, walletTrades, followTokens } from 'madeonsol-x402';
const api = new MadeOnSolREST({ apiKey: process.env.MADEONSOL_API_KEY });
const stream = api.earlyStream({
  subscriptions: [
    walletTrades(['YOUR_WALLET'], { sub_id: 'wallets', directions: ['buy'] }),
    followTokens(['YOUR_MINT'], { sub_id: 'token' }),
  ],
  onFrame(frame) {
    // early:observed is provisional instruction intent; early:outcome reports status.
    // Correlate outcomes through frame.data.observed_event_id.
    console.log(frame);
  },
  onStatus(status) {
    if (['gap', 'fatal', 'reconnect'].includes(status.type)) console.error(status);
  },
});
stream.connect();
// Once subscriptions are ready (and any replay has completed), await each control:
// await stream.updateSubscription('wallets', { filters: { wallets: ['NEW_WALLET'] } });
// await stream.updateSubscription('wallets', { format: 'compact-v1' });
// await stream.unsubscribe('token');
// stream.close();
```

Replace placeholder addresses before running. The repository example is
`packages/madeonsol-x402/examples/early-stream.mjs`: build the SDK, set
`MADEONSOL_API_KEY` plus exactly one of `EARLY_WALLETS` or `EARLY_MINTS`, and run
that file with Node 22+. It uses the platform WebSocket; `WebSocketImpl` can inject
`ws`.

- Token acquisition uses the existing REST client on every connection. Tokens
  do not expire automatically; the SDK never schedules rotation. Browser-compatible
  handshake authentication uses `?token=`, which must remain redacted in proxy logs.
  Credentials and raw REST/transport errors are excluded from SDK diagnostics.
- Transient disconnects retry with jittered exponential backoff (500 ms to 30 s;
  10 consecutive reconnect attempts by default). Authorization closes have a
  three-rejection budget. A connection-limit close waits at least 60 s. Policy
  rejection stops. Token acquisition, handshake, controls and replay have 15 s
  deadlines. There is no additional application heartbeat; the gateway's WS
  ping/pong remains responsible for detecting an idle broken connection.
- Up to 10 configured subscriptions share one connection; server tier/connection
  caps still apply. Updates and unsubscribe require a connected client and one
  control at a time. Await each call. The SDK replaces local settings only after
  server acknowledgement. Omitted filters remain; `{}` clears them. Use `subscribe(control)` to add a new subscription after connecting. An empty
  initial array is supported for list-management-only sessions.
- Frames are handled in arrival order. Async `onFrame` handlers are awaited, and
  an exception/rejection stops the client without advancing past that event.
  The pending queue is bounded to 2,048 frames / 8 MiB, measured in UTF-8, by default.
  Overflow stops with `client_queue_limit`; expensive work should leave the handler.
  Closing invalidates pending work and cancels retry/replay/control timers.
- `getCursors()` returns separate safe checkpoints per subscription. During replay,
  new live traffic is delivered but cannot advance the checkpoint until a complete
  replay finishes. A disconnect mid-replay resumes from the prior safe point.
  Incomplete replay or a reported gap freezes the checkpoint. A later complete
  recovery can resolve it; `acceptGap(subId)` explicitly skips to received progress
  once replay ends if the caller accepts the missing range. No gap is silently
  accepted. Process-local replay is bounded and is never durable history.
- Persist cursors with their matching subscription settings outside the handler,
  after processing has completed. To restart, put each saved cursor in that
  subscription's `resume`. Recent IDs are deduplicated per subscription and event
  type (10,000 IDs per subscription). Deduplication is bounded and in-memory:
  downstream processing must tolerate duplicates after eviction/restart.
- `full` remains the default. `compact-v1` is expanded before the application
  callback. The local managed client costs more CPU than a minimal reader; no
  zero-overhead or customer lead-time claim is made. Keep handlers short. Current
  protocol coverage is partial and all observations remain provisional.

Saved lists (migration 479) are applied in production and the managed client is published
(madeonsol-x402 4.1.0). Client-side receipt benchmarks are described in one place,
/solutions/shredprism-stream#benchmark-by-channel: per channel against processed and confirmed
provider notifications (2026-10-08), plus the earlier launch-only run. Migrations and locks were
not sufficiently measured and SPL Token token changes have no reference there. The local SDK audit is
`docs/audits/early-sdk-client-2026-10-07.md`.


## Private saved wallet lists

The early gateway stores named lists separately from the confirmed wallet-tracker
watchlist. Its authenticated stream-token owner is authoritative; a request cannot
supply another user ID. These lists are never added to public KOL/alpha rosters and
do not change upstream wallet-tracker subscriptions.

On a connected SDK client (it may start with `subscriptions: []`):

```js
const created = await stream.manageWalletList({
  op: 'create', name: 'My wallets', wallets: ['YOUR_WALLET'],
});
await stream.subscribe({
  type: 'subscribe', sub_id: 'private-trades', channels: ['early:trades'],
  wallet_list: created.list.id,
});
const changed = await stream.manageWalletList({
  op: 'replace', id: created.list.id, revision: created.list.revision,
  name: 'My wallets', wallets: ['ANOTHER_WALLET'],
});
// list returns metadata + wallet_count; get returns the actual wallet array.
await stream.manageWalletList({ op: 'list' });
await stream.manageWalletList({ op: 'get', id: created.list.id });
// Explicitly delete with the current revision when no longer needed:
// await stream.manageWalletList({ op: 'delete', id: created.list.id, revision: changed.list.revision });
```

Replace the placeholder addresses, wait for the SDK `ready` status, and await each
control. Raw WS uses `{type:"wallet_list", ...control}` and receives
`{type:"wallet_list_result", op, list?, lists?, limits}`. Revisions are decimal
strings. `replace` supplies the whole name/address snapshot; `replace` and `delete`
require the current revision. A conflict leaves the existing stored row intact.
`list` is summary-only so a full BUSINESS account cannot exceed the control-frame
byte budget; use `get` for one list's addresses. There is no new REST endpoint.

Limits reuse existing tier budgets: ULTRA 10 lists / 100 wallets per list;
BUSINESS and ENTERPRISE 20 / 500. Lists contain 1..limit unique Solana addresses,
with a unique owner-scoped name of 1..64 characters. Delete an empty group instead
of subscribing with an empty wallet filter. The SDK supports 10 simultaneous
subscriptions; server subscription and connection caps remain authoritative.

A subscription's top-level `wallet_list` is an ID, valid only for `early:trades`.
Do not combine it with `filters.wallets` or `filters.actors`; direction, amount,
protocol and other compatible filters still intersect. Omitting it on update
keeps the reference; explicit `wallet_list: null` detaches it, so supply replacement
filters if the subscription should remain wallet-scoped.

Subscribe/update acknowledgements and `list` controls report
`wallet_list: {id, revision}`. A successful edit atomically replaces each active
subscription's in-memory index, then emits `wallet_list_updated` with the active
revision. An update arriving during replay ends the old replay as incomplete;
clients retain the prior safe cursor. Reconnect reloads the current saved revision,
and replay uses that current list, not historical list membership.

Local successful writes are applied to current owner subscriptions immediately.
Edits from another process are discovered by a 5-second refresh. Unchanged rows
return their revision without resending wallet arrays. A previous snapshot is valid
for at most 15 seconds after a successful read. On deletion, missing access, failed
application, excess wallets after tier downgrade, or expiry, the affected binding
is removed and `wallet_list_unavailable` is sent. The SDK surfaces it as a `gap`;
resubscribe after correcting the cause. A transient failed read does not extend TTL.
Normal auth expiry/downgrade enforcement still applies.

A dedicated one-connection database pool handles controls/refresh, with 2-second
query deadlines and at most 8 admitted list queries. At most 256 distinct active
owner/list references are materialized per gateway, within the existing 100,000
binding budget. Overload fails explicitly. Decode, matching, live publication and
replay never query the database; delivery checks the local snapshot expiry and tier
limit. Do not describe this bounded local implementation as zero-overhead or as
complete wallet transaction coverage.

Storage (migration `479_early_wallet_lists.sql`) enables RLS with service-only access
and serializes owner writes for quota/CAS correctness. Evidence:
`docs/audits/early-wallet-lists-2026-10-07.md`.
