Changelog

What's new in Streamlock.

v0.4.5

Sell page: stop mislabeling the protocol allocation as a game loss; read share history from chain

The Sell panel showed "Game results have modified your share" whenever a holder's effective share was below 100% — even when no game had ever touched the stream. A sub-100% share most commonly comes from the standard ~1% protocol slice taken at stream creation, not from a game. At the same time, the "session history" details read Streamlock's first-party database, which is blind to third-party game operators — so a stream that was modified by a real on-chain game showed "No session history found." We confirmed both cases against live mainnet streams (one at 99% from a pure creation-time allocation, one at 89% from a genuine on-chain settlement applied by an external operator).

What changed

  • New /api/entitlement/deltas reads the authoritative, operator-agnostic on-chain delta trail — it scans the stream's apply_delta_to_ledger transactions and decodes each (player, delta_bps). A third-party operator's game now shows up here even though it never reaches our own database.
  • The Sell panel now distinguishes cause from effect. It only says "Game results have modified your share" when on-chain delta records actually exist for the holder. When the deviation is purely a creation-time protocol allocation, it says so plainly ("X% was allocated at stream creation") with no game framing.
  • Game history is now chain-anchored. Each delta in the details view links to its settlement transaction on Solscan, and the breakdown separates game deltas from the protocol allocation so the numbers reconcile to the displayed share.
v0.4.6

Game Sessions tab: 'Your Activity' now reads on-chain so third-party operators' games show up

The "Your Activity" panel on a token's Game Sessions tab used to read our first-party Mongo game-sessions index, which only contains sessions registered through Streamlock's own operator. A real on-chain settlement applied by an external operator would move the player's entitlement bps on-chain but never appear in this panel — the same blindness we fixed for the Sell page in v0.4.5.

What changed

  • "Your Activity" now uses the on-chain delta reader (/api/entitlement/deltas) introduced in v0.4.5. The component lists the connected wallet's streams on the token, decodes the apply_delta_to_ledger trail on each stream's ledger PDA, and aggregates — operator-agnostic.
  • Grouping switched from "by session" to "by stream." The on-chain apply_delta_to_ledger instruction carries (stream_id, player, delta_bps) but no sessionId, so per-stream is the natural grouping (and reflects what a player actually cares about: "what happened to each of my streams"). Each row now links to its settlement transaction on Solscan.
  • Mongo /api/game-sessions/player-deltas is no longer consumed by any rendering component. The route is left in place for future use; the only callers were SellComponent (migrated in v0.4.5) and now GameSessionsTab.
  • The "All Sessions" list below still reads the first-party Mongo registry — that's a separate completeness gap (finding every GameSession PDA on-chain by token requires a getProgramAccounts filter, larger lift) and is tracked for a future cycle. Sessions registered with us continue to show up there as before.
v0.4.4

Recent Activity: buys now logged server-side at confirmation so they can't be dropped on tab close

Buys were only written to the Recent Activity feed as a side-effect of /api/user-streams/save, a best-effort call the browser fires after a buy confirms. If the user closed the tab — or the network blipped — before that call landed, the buy succeeded on-chain but never showed up in the feed. (We found one such case: a confirmed 0.025 SOL buy that was missing from the feed.)

What changed

  • Buy activity is now logged server-side in /api/transaction/confirm, the moment the on-chain confirmation resolves without error. The buy metadata rides along in the confirm request, so the row is written before the client needs to do anything else.
  • Decoupled from /api/user-streams/save — the old activity-log side-effect there has been removed. A tab close after confirmation no longer loses the event.
  • Logging stays idempotent via the unique txSignature index, so retries and the existing /api/token-activity/log path can't create duplicates.
v0.4.3

Token banner: fixed 3:1 aspect ratio so it renders uncropped on every screen

The token-page banner box used a fixed pixel height (h-32 sm:h-36) with a fluid percentage width, so its aspect ratio changed with viewport — roughly 4.6:1 on desktop versus 2.5:1 on mobile. Combined with object-cover (which crops to fill), this meant the visible slice of the image differed on every screen, on a different axis each time: a desktop-composed banner lost up to ~36% of its width on mobile, and a 3:1 banner lost ~35% of its height on desktop.

What changed

  • Banner box is now a constant 3:1 aspect ratio (aspect-[3/1]) at every viewport — TokenAboutSection, the TokenEngagementTabs placeholder, and the TokenLaunchSummary upload preview. A 1200×400 image now displays uncropped on desktop and mobile; it simply scales down on smaller screens.
  • Upload guidance corrected — the launch form previously recommended two incompatible sizes (1200×400 or 1920×480). The box can only match one ratio, so the help text is now a single recommendation: 1200×400 px (3:1), with a note to keep logos/text centered.

Note

The desktop banner is now slightly taller (~144px → ~220px at full column width) — the cost of showing the whole image instead of a center crop.

v0.4.2

Frontier Hackathon — mini-game + World ID gate shipped (5/5)

All five Frontier Hackathon deliverables are now shipped on mainnet. Deliverable #4 (Mini-game) and #5 (World ID Integration) landed on 2026-05-10 in the standalone streamlockfun-minigame repo, deployed to Fly.io (operator + WSS) and Vercel (web client).

What's new

  • Mini-game — Best-of-three Rock-Paper-Scissors with amount-based wagers and snapshot semantics. Consumes only @streamlock/operator-sdk and @solana/web3.js — zero imports from this monorepo. That is the receipt that the SDK is a real third-party integration boundary, not a vanity wrapper around internal helpers.
  • World ID gate — Optional per-match sybil gate. Creator picks verifiedOnly; both sides bind (wallet ↔ World ID nullifier) via developer.world.org/api/v4/verify/{rp_id}. Strict 1 human ↔ 1 wallet (SQLite UNIQUE on nullifier and wallet), plus a SAME_HUMAN guard at join so one human cannot play both sides across two wallets. Open (non-gated) matches unchanged.

Scope decision — World ID

Scope shifted from buy-gate to mini-game per-match gate. The original feasibility plan (docs/architecture/WORLD_ID_FEASIBILITY.md) targeted a buy-path gate, which would have forced every existing mainnet holder onto World ID before their next trade. The game-gate version keeps state off-chain (no Solana program change, no audit re-scope) and proves a stronger primitive story — an external operator stacking a sybil layer on top of Streamlock's entitlement ledger.

Updated surfaces

  • FRONTIER_HACKATHON.md — deliverables table flipped to 5/5 shipped; new §4 and §5 with architecture, sybil guards, and a Decision/Alternative/Why/Revisit-if breadcrumb for the World ID scope shift.
  • /frontier page — mirrors the markdown doc; deliverables table updated, hero copy moved from "all three mainnet gates cleared" to "all five deliverables shipped", and full SectionCards added for #4 and #5 with the same scope-decision callout in an amber-bordered Decision block.
v0.4.1

Token card · LST yield now shows live unrealized value

The LST Yield row in every token card's "Yield & Rewards" tab used to read the on-chain total_yield_earned field — which only ticks up at unstake time, so it sat at 0 for any pool that hadn't been unstaked yet. It now shows the live mark-to-market value instead.

What's new

  • Live LST yield — pulled from /api/pool/staking-status, computed as (JitoSOL_balance × current_JitoSOL→SOL_rate) − staked_sol_principal. Refreshes every 30s.
  • Label updated: "LST Yield Earned""LST Yield (Live)" so the semantics match (mark-to-market, not paid-out).

Under the hood

  • TokenStatisticsCard now fetches /api/pool/staking-status (already deployed; was unused by the UI). Result is client-cached for 30s and dedup'd via the standard request-deduplication helper.
  • The endpoint reads the JitoSOL stake-pool account on every call and computes the rate from total_lamports / pool_token_supply, so the displayed value reflects the current epoch's exchange rate.
v0.3.13

Sell-fee start raised from 25% → 50%

The sell-fee linear decay now starts at 50% (was 25%) and decays to 1% over the unlock window. This release ships only the frontend / off-chain side of the change. The on-chain switch is a separate one-tx call against the global TokenFactoryConfig PDA via update_token_factory_config (the existing admin endpoint at /api/admin/update-fees); no program redeploy is required.

Why

A sniper exiting at unlock-open with a 25% sell fee profits whenever they expect price to drop more than ~21% before fees decay — below the realistic price-drop range for normal launches. Raising the start to 50% lifts that break-even threshold to ~47%, which sits above the typical price decay during healthy unlock distribution and well above orchestrated pump-dump payoff thresholds.

50% is the lowest setting at which day-1 front-running becomes structurally unprofitable for nearly any realistic launch. Going higher (80%, 99%) was rejected after game-theoretic + two-sided market-dynamics analysis: with the existing 32:1 buy-cap-to-sell-cap ratio (in SOL terms at first unlock), price defense is already structurally handled, and higher sell-fee starts only redistribute more value from retail sellers to fee revenue and to professional dip-buyer bots — without changing final price, mean exit timing, or tail-volume concentration.

Caps are intentionally unchanged: the 0.5% sell cap and 2% buy cap do a different job (atomic-block flash-dump prevention, single-epoch granularity), and sensitivity analysis confirmed both are at their sweet spots.

What changed (frontend)

  • src/utils/fees/feeCalculations.tscalculateSellFee now returns 5000 bps at unlock-open and decays linearly to 100 bps; comments updated.
  • src/components/trading/SellComponent.tsx — sell-panel UI strings updated from "25%" to "50%".
  • src/i18n/en.ts, src/i18n/zh.ts — FAQ and "Reduced Sell Pressure" copy updated to "~50%".
  • scripts/utils/initTokenFactoryConfig.ts — devnet config-init default bumped from 2500 → 5000 (only used for fresh PDA initialization).

On-chain rollout (completed 2026-05-06)

  • Devnet flipupdate_token_factory_config via the StreamlockFun admin endpoint. Tx: 39bv1X8Vue5L94KkPCR62hZD3HxSDMTtvoBiP2hsFP3sYW5Nj3TQjeA53uzVmwN3bop5ofV4xrXievj1ToBSNcec. Config PDA 9KukC4jsAvmwKmqC8aDWQePk3NHhNqLmyn2HALphrwUR. Verified: byte 161 = 5000, sibling fields untouched.
  • Mainnet flip — signed by config-authority HLWfdZTm…CCr from streamlock-blockchain (StreamlockFun's prod admin endpoint signs with router_authority 8wk8NQc8…wt, which fails the has_one = authority constraint on mainnet — config init used a separate keypair). Tx: 3NPPeRoDj7epzpBg4iueVLNQUzTvJLxeW8M35TKD9pMtqKW1iE7mVbUBaUQrdLXtuP2MKxaicxRu522zmZxPcKB6 at slot 417822634. Config PDA G2pPshQ5Yx665nQXbq7jCYo4HhiKr7Y2SxwRogRta1qW. Verified at finalized commitment: byte 161 = 5000, max_buy_fee_bps (1300) and sell_fee_end_bps (100) untouched. Fee 0.000005 SOL, 5588 CU. Step 0 gate passed at flip time (0 of 8 mainnet pools mid-unlock).
  • Reproducible / rollbackscripts/update-sell-fee-start-mainnet.ts in streamlock-blockchain repo. Same script with 2500 --send reverts.

Future housekeeping (not behaviorally required)

  • Sync ProtocolParams::defaults() in programs/tokenFactory/src/lib.rs from 2500 → 5000 the next time a program upgrade ships, for source-of-truth consistency on fresh PDA initialization. Live PDA holds the actual value, so this only matters for new clusters / re-inits.

What's not changing

  • sell_fee_end_bps (1% — terminal value of decay) — unchanged.
  • sell_cap_sol_out_per_epoch (0.5% of liquid+staked SOL, adaptive per v0.3.12) — unchanged.
  • buy_cap_token_out_per_epoch (2% of supply per epoch) — unchanged.
  • max_buy_fee_bps (13%) — unchanged. Asymmetry vs the new 50% sell-fee start preserves a 37–47% arb spread that pulls retail dip-buyers into unlock windows.
  • Freeze duration, unlock window duration, milestone math — all unchanged.

Decision rationale and full simulation findings: docs/architecture/SELL_FEE_50PCT_DECISION.md (to be added).

Rollback

Single tx — POST /api/admin/update-fees {"sellFeeStartBps": 2500} reverts on-chain. Frontend revert via git revert of this release. No state migration. Sell fee is computed fresh per trade so reverts apply immediately.

v0.4.0

Giveaway page · live $LOCK swap raffle leaderboard

Added a new public /giveaway page to host our 500-followers celebration giveaway (3 SOL across 4 ways to win) with a live, ranked leaderboard for the $LOCK swap raffle.

What's new

  • /giveaway page — full overview of the 4-track giveaway: $LOCK Swap Raffle, $DIAMOND Holder Raffle, Best Post, Engage to Win. Eligibility note + link to the original announcement on X.
  • Live leaderboard for Track 1 (Powered by @torqueprotocol) — wallets ranked by total $LOCK bought during the eval window, podium top-3 + ranked table, auto-refresh every 30s, full address shown per row.
  • Phase-aware countdown — surfaces "Eval ends in", "Draw imminent", and "Claim window ends in" based on the current epoch state.

Under the hood

  • GET /api/giveaway/leaderboard — server-side proxy hitting Torque's latest-eval-results endpoint with our API token. Token never reaches the browser.
  • 30s SWR cache to keep the page snappy without hammering Torque.

Nav

  • Sidebar (desktop) and mobile drawer now expose Giveaway.
  • Operator removed from primary nav (page still reachable via direct URL).
v0.3.12

Adaptive sell cap on mainnet — caps now scale with pool depth

token_factory now recomputes sell_cap_sol_out_per_epoch from current pool depth at every 5-minute epoch rollover, instead of leaving it frozen at the value stamped at initialize_pool. Mainnet binary deployed at slot 417679106.

What changed

Adaptive sell cap. The sell cap is now derived as (sol_vault.lamports + staked_sol_lamports) × 50 / 10_000 — 0.5% of virtual reserves (liquid + LST-staked SOL), recomputed at every epoch rollover. Previously, the cap was set once at pool launch from initialSolLamports and never updated, so a pool seeded with 1 SOL kept a 0.005 SOL/epoch cap even after growing to 100 SOL of depth. Marketing copy and docs already described the cap as "0.5% of liquidity" — this brings the implementation in line with the documented behavior.

Effect on the 8 mainnet pools. All in Case A (current depth ≥ launch seed), so first rollover after deploy only liberalizes:

| Pool | Δ at first rollover | |---|---| | Btk8CNWa… | +1474% | | 8HbJFv7P… | +149% | | 39PoK4QY… | +61% | | 9QDCxXuD… | +4% | | H5pGT5YJ… | +2% | | 7zVrjUbph… | +0% | | ARdxny4v… | +0% (canary) | | 7JEAFSf… | -100% (1000 SOL admin override on a 0.01 SOL dormant pool — economically irrelevant) |

No data migration needed — all 8 pools are already on the 409-byte PoolConfig layout.

Game-theoretic note. Combined with the existing 25%→1% decaying sell fee during unlock windows, the cap now functions as a hard rate ceiling against flash-crash drain while the fee handles anti-rush incentives via Pigouvian taxation to remaining holders. Caps that adapt downward during sell-offs also dampen panic — the budget for the next epoch reflects the lower depth after the previous epoch's sells.

Other ship items in this release

Auth gate on /api/update-caps. Closed an unauth-write hole: the route previously accepted POST requests without authentication, relying only on the on-chain update_authority constraint to reject malicious calls. The server signs with the router_authority keypair regardless of caller, so anyone hitting the endpoint could spend router-authority SOL on no-op writes or zero-out caps for any pool whose update_authority is the router. Now requires Authorization: Bearer ADMIN_API_SECRET (or a Privy session whose wallet is in the debug whitelist), mirroring set-target-price.

Tooltip on Throughput Caps panel. caps.titleTooltip (en + zh) explains the cap auto-scales with pool depth, so users hovering the panel header see "0.5% of (liquid + staked) SOL recomputed every 5 minutes."

Operational notes

  • update_caps admin overrides are now transient — they get overwritten at the next epoch rollover. The on-chain instruction still works, but persistent admin caps require re-applying after each rollover. Practical effect: the only mainnet pool with an admin override (#1, the dormant 1000 SOL pool) will see its override wiped on first rollover. Acceptable — that pool has no real liquidity.
  • The streamlock-blockchain agent owns asynchronous verification via a +6h re-snapshot of the 8 pools to confirm post-rollover caps match the formula.
  • IDL and PROGRAM_IDS_REFERENCE_BOOK.md synced from streamlock-blockchain.

What's not changing

Token economics (fees, milestones, freeze, unlock windows), holder balances, pool data structure, the buy cap (still flat 2% of supply per epoch), and the sell-instruction surface — all unchanged. This is purely a behavioral fix to one stored cap value.

v0.3.11

/frontier page reflects Operator API + SDK live on mainnet

The Frontier Hackathon page now shows all three mainnet gates as cleared. Deliverable #3 (Operator API + SDK) flips from "Spec locked" to "Shipped" with a full breakdown of what landed.

What changed

Deliverable #3 status: shipped. Phases 1–8 went live across 2026-04-25 → 2026-04-28 — API-key auth layer, public /v1/operator/* namespace (16 routes), @streamlock/operator-sdk v0.1.0, /admin/operators enrollment UI, OpenAPI 3.1 schema at /v1/openapi.json, and an authenticated WSS event feed at /v1/operator/stream on price-wss. Validated end-to-end on mainnet by Test 15 — five real on-chain txs against a hidden test token, total cost 0.0284 SOL.

New Post-launch hardening subsection. Documents the DoS audit follow-ups: D-6 per-API-key rate limit (120/min keyed on sk_xxx... prefix, moved inside authenticateOperator for uniform 16-route coverage) on 2026-04-29, and D-1 distributed rate limiter (Upstash Redis-backed, exact counts across Vercel regions, fail-open to in-memory) including the Cloudflare → Vercel IP-detection fix that was masking all per-IP limits before.

Mainnet validation block. Test 15 details inline — five txs against SMKT0419, loser bps 9900 → 8900, winner new entry at 1000, 6 rollout bugs surfaced and fixed in-flight (most material: SDK now awaits confirmation after sendRawTransaction).

Notes

Page-only update — no API or on-chain changes in this release.

v0.3.9

Token page scroll fix, TopMovers % polish, docs sync

Small patch release. Fixes a UX bug on the token page, cleans up the TopMovers percent-change rendering, and brings the public docs in line with the fee-splitter remediation that shipped on 2026-04-26.

What changed

Token page no longer auto-scrolls to the chat box on load. The Token Chat component's "scroll to latest message" effect was using scrollIntoView, which walks up every scrollable ancestor — so on a fresh page load the page would scroll down to put the chat input at the top of the viewport. Replaced with a direct scrollTop = scrollHeight on the chat's own overflow container so the page scroll is never touched.

TopMovers % cells render consistently. The h4 / h24 / d7 cells had ad-hoc rendering per column with a small glitch where a value rounding to 0.00% still showed the green up-arrow. Centralised the sign / color / arrow logic in a single helper (getRoundedPercentState) so every percent cell — including the mobile h24 column — handles up / down / flat the same way.

Docs catch-up for fee-splitter remediation. Updates across docs/ and gitbook/ reflect the post-2026-04-26 reality: cron transfer block removed, DEFAULT_LP_FEE_BPS lowered 6000 → 3000, TICKET + DIAMOND-v2 admin-recalibrated. Affected docs cover LP provision, fee structure, protocol revenue, holder rewards, referrals, and the dynamic-fees implementation note.

v0.3.10

Recent Activity feed shows sells and LP injects

The Recent Activity tab and the small activity card on each token page used to show only buys. They now show three event types — buys, sells, and LP direct-injects — each with its own icon and label.

What changed

New token_activity_events collection. Backs the feed instead of user_streams, which was buy-only by construction. Each row carries a type discriminator (buy / sell / lp_inject) plus per-type fields (SOL in / token out for buys, token in for sells, SOL injected / shares for LP). Idempotent on txSignature via a unique index.

Three write paths.

  • Buys: server-side dual-write inside /api/user-streams/save. Covers both regular buys and excess-stream-execute, since the latter calls user-streams/save internally.
  • Sells: client-side log POST after settle confirmation, fired from all three settle entry points in SellComponent (handleSettleStream, handleDripClaim, handleClosePosition).
  • LP injects: client-side log POST after the inject tx confirms in InjectLiquidityPanel.

All client-side writes are fire-and-forget — a missed log doesn't block the user's tx, and the next render of the feed simply omits that event.

Backfill. scripts/backfill-token-activity.ts seeds the new collection with type:"buy" rows from existing user_streams docs. Idempotent against the unique txSignature index, supports --dry-run. Run once per database (devnet, mainnet) after deploy or the feed looks empty for older tokens.

UI rendering. RecentActivityCard and the Activity tab in TokenEngagementTabs now branch icon and label by event type — green ↑ for buys, red ↓ for sells, blue droplet for LP injects. Added sold and lpInjected i18n keys (en + zh).

Notes

The feed is best-effort, not an audit log. If a client crashes between tx confirm and the log POST, that one event is missed — acceptable for a UI surface, not for reconciliation. Sell rows currently show only tokenAmountIn because settle-stream doesn't compute SOL proceeds at settle time (those are computed later via claim-proceeds). Adding SOL-out badges to sell rows is a future enhancement.

v0.3.8

Hardened admin API endpoints + global per-IP rate limit

A defensive-security audit on 2026-04-27 found that six /api/* routes which sign Solana transactions with the router_authority keypair had no HTTP-layer authentication or rate limiting. Because the server's signature is what makes the on-chain update_authority / router_authority check pass, the on-chain access control was effectively a no-op for any caller who could reach the HTTP endpoint. This shipped fixes for that gap and raised the floor on the rest of the /api/* surface.

See CENTRALISATION_RISK.md for the full write-up, the affected routes, and the open structural follow-ups (multisig migration, on-chain timelock, Vercel log audit).

What changed

Step 1 — gate the six router-authority-signing routes

| Route | New gate | |---|---| | set-target-price (POST + GET) | verifyAdminOrWhitelisted — Bearer ADMIN_API_SECRET OR a Privy session whose wallet is in debug_whitelist | | update-all-streams (POST + GET) | verifyAdminAuth; plus a MAX_STREAMS_PER_CALL = 50 cap on per-request fan-out | | crank/unstake-via-swap (POST) | verifyAdminAuth | | recover-settle (POST) | verifyAdminAuth | | refresh-lifecycle (POST + GET) | transactionBuildLimiter (20/min/IP) — kept unauthed because frontend lifecycle components call it | | update-stream-amount (POST) | transactionBuildLimiter (20/min/IP) — kept unauthed because SellComponent and /api/settle-stream call it |

Step 2 — defense-in-depth across the rest of /api/*

  • New proxyApiLimiter (240/min/IP) applied in src/proxy.ts to every /api/* and /v1/* request as a global backstop. Per-route limiters and auth are still primary; this exists so unaudited routes still have a ceiling.
  • publicReadLimiter (30/min/IP) added to seven read-only RPC routes that previously had no rate limit and were the largest Helius-quota burn vectors: pool-accounts, quote, balance, account-info, oracle-price, sol-balance, token-account.

The in-memory rate limiter no-ops in non-production (src/lib/rateLimit.ts:49), so localhost and preview deploys are unaffected.

Required ops actions before promotion

  1. Set ADMIN_API_SECRET in Vercel Production env (32+ random bytes). Without it the four pure-admin routes return 500.
  2. Confirm debug_whitelist Supabase table contains the operator wallets that need to use /debug set-target-price.
  3. Audit Vercel logs for prior calls to the six previously-unauthed routes from non-operator IPs (see CENTRALISATION_RISK.md § "Was it ever exploited?").

Smoke test

scripts/api-tests/test-admin-auth-gates.sh exercises every gated route with no-auth / wrong-secret / right-secret and verifies the expected status codes (19/19 passing on dev as of 2026-04-27).

v0.3.5

Cron Retry v2 — Use Rate-Limit-Aware retryWithBackoff

Hotfix on top of v0.3.4. The naive inline withRetry we shipped yesterday (3 attempts × 200ms base × 2.5^n = max 1.95s total backoff) was independent per-call — 20 concurrent pMap workers each retried independently with no shared state, so when one hit a 429 the others kept hammering the same RPC and re-triggered the rate limit in lock-step. Net effect: leaderboard stuck consistently at 4 holders for TICKET despite ground truth being 8.

Local repro of the same code against the same 76 user_streams docs and the same Helius mainnet RPC resolves 45/45 — meaning the bug was specific to production load patterns that local single-shot runs don't reproduce.

Switched to retryWithBackoff from src/lib/solana/rpc.ts

The repo already had a smarter retry helper that's:

  • 4 retries (was 3), 500ms base × 2^n = up to 7.5s+ total backoff per call (was 1.95s)
  • Shared rateLimitBackoffUntil clock across all concurrent callers — when one worker hits a 429, every other worker about to fire also waits for the backoff window to elapse before sending. Removes the lock-step re-trigger pattern.
  • Adds a consecutiveRateLimits × 500ms penalty per consecutive 429 — exponential pressure relief if the RPC is sustained-throttling.
  • Distinguishes 429 (rate limit, retry) from 403 / "credits exhausted" (provider gone, give up immediately) — no point retrying when the API key is dead.

Diff: 9 added, 30 removed (the inline helper was deleted in favor of the import).

Follow-ups still open

  • Verify Vercel prod's RPC_URL is Helius, not falling back to public mainnet-beta. If it's the public RPC, no amount of client-side retry will save it; that endpoint sustained-throttles past any reasonable backoff window.
  • Reduce pMap concurrency from 20 → 8 if v0.3.5 is still flaky. 20 concurrent workers × ~76 streams × 2 RPC calls per stream = 152 in-flight peak; even Helius free tier can throttle that.
  • Soft-delete in services/mongodb-api/src/utils/tokenHoldings.ts: replace the destructive deleteMany with a consecutiveMissing counter so transient under-counts no longer drop users from token_holdings immediately.
v0.3.6

Fee Splitter Remediation — Stop Over-Paying LPs

For pools with the LP fee splitter initialized (TICKET, DIAMOND-v2 on mainnet), two independent code paths combined into a fee distribution that over-paid LPs by ~3× their intended 30% share, with the protocol's routerAuthority wallet eating the difference. Neither bug had caused user-visible damage yet because neither splitter pool had reached its first milestone. This release lands the code half of the fix; an admin-call backfill against the live pools is the remaining manual step.

What was wrong

  • 2026-04-12 auto-init at confirm redirected pool.fee_recipient to the fee_vault PDA so trading fees flow into the splitter directly.
  • The accumulator-distribute cron (older, predates the redirect) still transferred 50% of unlock_fees_collected from routerAuthority into fee_vault after each milestone, assuming fee_vault was empty.
  • lp_fee_bps = 6000 was calibrated for the pre-redirect world (60% of the protocol's 50% portion = 30% of total). Post-redirect, fee_vault holds 100% of trade fees, so 60/40 became 60% of total — 2× over-pay. Combined with the cron's redundant transfer, the effective LP over-pay compounded to ~3×.

Net effect per 100 SOL of unlock-window trade fees on a splitter pool: LPs received ~90 SOL (intended: 30), routerAuthority lost ~40 SOL out of pocket (intended: +20).

Full math walkthrough + decision tree: docs/architecture/FEE_SPLITTER_REMEDIATION.md.

What this release ships

  • accumulator-distribute cron: the protocolShare → fee_vault transfer block (route.ts:485-530) is removed. Cron now only pays holder + creator distributions.
  • /api/initialize-token/confirm: DEFAULT_LP_FEE_BPS lowered from 6000 → 3000 so future splitter pools launch with the correct 30/70 LP/protocol calibration.
  • New admin route /api/admin/update-fee-splitter to recalibrate lp_fee_bps, protocol_wallet, or enabled on existing splitter pools (uses the existing on-chain update_fee_splitter ix at lib.rs:3915).
  • New backfill script scripts/recalibrate-fee-splitter.ts — idempotent, dry-run default, mirrors the staking backfill pattern.
  • Helper script scripts/read-fee-splitter.ts — direct mainnet read of FeeSplitterConfig data for verification.

What's still required after deploy

  • Run recalibrate-fee-splitter.ts against TICKET and DIAMOND-v2 with prod admin credentials to flip lp_fee_bps from 6000 → 3000. Until this lands, the cron change leaves the splitter slightly under-paying LPs (since fee_vault holds 100% of trade fees but lp_fee_bps still says 60% — wait, that's still 60% of total = 60%, which is over-pay relative to intended 30%; the over-pay continues until the recalibration runs).
  • Maintain routerAuthority (8wk8…) at ≥0.5 SOL working capital. The cron pays holder + creator from this wallet (~50% of unlock-window trade fees per cycle); the auto-sweep on each sync_fee_splitter replenishes it from the splitter (70% of every fee_vault deposit). Net positive over time, but needs a buffer for cash-flow timing.

Pre-fix mainnet state (snapshotted)

| Pool | lp_fee_bps | total → LP | total → protocol | fee_vault unsynced | |---|---|---|---|---| | TICKET | 6000 (buggy) | 0.000801 SOL | 0.000534 SOL | 0.000680 SOL | | DIAMOND-v2 | 6000 (buggy) | 0 | 0 | 0.014382 SOL |

TICKET's ~0.0004 SOL historical over-distribution to the creator (sole LP at the time) is accepted as a beta-tester bonus, not reconciled.

Verification path

  1. Deploy this PR. Wait for Vercel to confirm the cron route reflects the change.
  2. Snapshot pre-recalibration state: npx tsx scripts/read-fee-splitter.ts > scripts/fee-splitter-snapshots/2026-04-26-pre-recal.txt.
  3. Dry-run: npx tsx scripts/recalibrate-fee-splitter.ts --dry-run --all. Expect both pools to show lp_fee_bps=6000 → would update to 3000.
  4. Live run, TICKET first: ADMIN_API_SECRET=<prod> npx tsx scripts/recalibrate-fee-splitter.ts --mint 8exhtA3JZvzBEkHb25kXoKYjeHs47P2DBHRqoHLLock. Verify on-chain: lp_fee_bps=3000.
  5. Live run, DIAMOND-v2: same with the DIAMOND-v2 mint. Verify same way.
  6. Optional smoke test: trigger an organic sync on either pool (an LP claim, or via a small buy + observe the next sync). Check total_fees_distributed_to_lp and total_fees_distributed_to_protocol increment with the correct 30/70 ratio.
v0.3.4

Orphan EntitlementLedger Backfill + Cron Retry Fix

Two related issues, both surfaced from a user-reported "I bought TICKET but I'm not in the leaderboard" report.

Backlog: 16 mainnet locksmith streams missing their EntitlementLedger

Between 2026-04-20 (TICKET launch) and 2026-04-25 (commit 0029b3d), every successful locksmith-vesting buy on mainnet created an on-chain PriceLockStream (and sometimes a paired TimeLockStream) without the matching streamlock_router::EntitlementLedger that records "this stream's owner is wallet X". After 0029b3d wired init_entitlement_ledger into buy-execute, no new orphans accrue — but the backlog of 16 streams across 4 mainnet tokens needed manual recovery.

| Token | Orphans | Tokens locked | |---|---|---| | TICKET | 12 (9 PL + 3 TL) | 84.49M | | DIAMOND v2 | 2 PL | 10.26M | | BUDDHAROID | 1 PL | 1.66M | | LPT0420 (internal test) | 1 PL | 1.78M |

Risk shape: not external — settle_stream_locksmith invokes token_factory::settle_sale_from_vault, which gates on router_authority: Signer == pool_config.router_authority (tokenFactory/src/lib.rs:2001-2006). A random wallet calling settle reverts before reaching the ledger lazy-init. The actual residual risk is internal: settle_stream_locksmith requires two signers (payer + owner_account); whoever signs owner_account becomes the ledger's permanent owner via init_if_needed. If a future cleanup script signs both roles with the router authority keypair, the ledger seals with owner = router authority and the buyer's proceeds are permanently attributed to the ops wallet (no instruction exists to update EntitlementLedger.owner after init). The backfill closes that hole by sealing each ledger with owner = buyer ahead of any settle call.

Backfill landed on mainnet (15 of 16 sealed; LPT0420 skipped as internal test): ~0.236 SOL rent paid by router authority, all ledgers verified on-chain with correct owner. Audit re-run reports orphans: 1 (the deliberately-skipped LPT0420). Tx sigs and per-row results in tmp/backfill-result-*.json manifests.

Tooling shipped:

  • scripts/audit-orphaned-streams.ts — paginates getProgramAccounts for every locksmith PriceLockStream + TimeLockStream, diffs against streamlock_router EntitlementLedger PDAs by stream_id, resolves each orphan's buyer (price-locks: from creation-tx signer; time-locks: by 30s temporal pairing with same-token PriceLockStream), CSV output.
  • scripts/backfill-orphan-ledgers.ts — reads the audit CSV, prechecks ledger PDA existence via getAccountInfo, reads EntitlementLedger.owner at byte offset 42 to flag wrong-owner cases as IRRECOVERABLE, dry-run by default, single init_entitlement_ledger ix per orphan with (stream_id, token_mint, owner=buyer) (program populates fee-aware initial chunk inline), idempotent re-runs via the precheck.

Cron retry fix — /api/cron/reconcile-holdings

The leaderboard count was flapping (4 → 7 → 6) post-backfill. Root cause: the cron does ~76 RPC reads per run in a tight burst with no retry, and the downstream deleteMany aggressively removes any wallet not in the current run's set. Even a 1% transient-failure rate cascades into a flapping leaderboard.

Fix: wrapped fetchPriceLockStream / fetchTimeLockStream / Streamflow getOne in a small inline withRetry helper — 3 attempts at 200ms / 500ms / 1250ms exponential backoff. Distinguishes "account doesn't exist" (returns null — not retryable) from thrown errors (network blips, 429s, malformed responses — retryable). The outer try/catch still treats sustained failures as empty, preserving existing semantics.

Not fixed in this change (logged for follow-up):

  • The cron's RPC URL — verify Vercel prod has HELIUS_RPC_URL set, not falling back to public mainnet-beta.
  • The destructive deleteMany on every run. Better: track consecutiveMissing per (tokenMint, userPublicKey) and only delete after N consecutive misses.

Verification

# audit (mainnet, expects orphans: 1 — LPT0420 only)
RPC_URL=… npx tsx scripts/audit-orphaned-streams.ts

# leaderboard for TICKET (post-cron-tick, post-deploy)
curl -H "X-Chain: mainnet" \
  "https://streamlock-mongodb-api.fly.dev/api/token-holdings/leaderboard?tokenMint=8exhtA3JZvzBEkHb25kXoKYjeHs47P2DBHRqoHLLock&limit=20"
# expect totalHolders=8, DKU4dTy1 with streamCount=2 / amt=18.94M
v0.3.7

token_holdings: replace destructive deleteMany with soft-delete (consecutiveMissing)

Structural fix on services/mongodb-api/src/utils/tokenHoldings.ts:reconcileHoldings. The previous version used deleteMany({ tokenMint, userPublicKey: { $nin: activeUsers } }) to remove any user not in the freshly-computed set on every cron run. That made every transient RPC failure a destructive write — if a single Helius 429 caused a stream to silently drop from positiveHoldings, the user vanished from token_holdings until the next successful run re-inserted them. Combined with the cron's high-concurrency RPC pattern, this produced the user-visible flapping that motivated v0.3.4 (naive retry) and v0.3.5 (rate-limit-aware retry).

Retries reduce the rate at which the destructive write fires; soft-delete makes the destructive write structurally impossible from a single bad tick.

What changed

reconcileHoldings now writes in three steps per call:

  1. Upsert positive holdings, resetting consecutiveMissing: 0. (The reset is the critical part — without it a flapping user could carry stale miss-count across cycles.)
  2. Increment consecutiveMissing for every existing doc on this token whose user is NOT in the current positive set. $inc on a missing field starts from 0, so backwards-compatible with pre-soft-delete docs.
  3. Hard-delete only entries whose consecutiveMissing >= SOFT_DELETE_THRESHOLD (currently 3).

Result shape gains a new incremented field; existing fields (upserted, removed, totalHolders) keep their meaning. The Vercel cron's typed flyApiPost<{...}> ignores the extra field — no compatibility shim needed.

Failure-mode comparison

| Scenario | Old behavior | New behavior | |---|---|---| | RPC succeeds for all streams | User stays in DB | Same — consecutiveMissing reset to 0 | | 1 transient RPC failure → user missing this run | Hard-deleted; vanishes from leaderboard | consecutiveMissing: 1, still in DB, still ranked | | User missing 2 consecutive runs | Already gone | consecutiveMissing: 2, still in DB | | User missing N≥3 consecutive runs | Already gone | Hard-deleted (genuinely closed position) | | 0 positive holdings this run (e.g., total RPC outage) | All users for token nuked | All users get +1; no one purged unless threshold reached over multiple ticks |

At threshold=3 and ~5min cron cadence, a genuinely closed position is purged within ~15 min. That's slightly slower freshness than before (was instant), but trades that for full immunity to single-tick RPC failures — the overwhelmingly more common case in practice.

Schema migration

TokenHoldingDocument.consecutiveMissing is added as optional (?: number). Pre-existing docs lack the field; MongoDB's $inc on a missing field starts from 0, so the next miss for an old doc sets it to 1, the next to 2, etc. No batch migration script required — the field populates lazily as docs cycle.

If for any reason you need to force purge old behavior, that's db.token_holdings.deleteMany({ consecutiveMissing: { $exists: true, $gte: 3 } }).

Index considerations

The two existing indexes still cover writes:

  • { userPublicKey: 1, tokenMint: 1 } (unique) — covers Step 1 upserts
  • { tokenMint: 1, rank: 1 } — covers leaderboard reads

Step 2's updateMany({ tokenMint, userPublicKey: { $nin: [...] } }) uses the unique compound index. Step 3's deleteMany({ tokenMint, consecutiveMissing: $gte }) does a tokenMint-scoped scan — at the current ~76-row scale this is trivial. If the collection grows past tens of thousands of rows per token, add { tokenMint: 1, consecutiveMissing: 1 } opportunistically; not needed today.

Deploy

services/mongodb-api/ is a separate Fly.io service. After this commit lands on main, ship it with:

cd services/mongodb-api
fly deploy

The Vercel app (StreamlockFun frontend) does not need a separate redeploy for this change — the cron route's contract with the upstream is unchanged.

Verification

After deploying, trigger the cron and confirm the response includes incremented:

curl -H "Authorization: Bearer $CRON_SECRET" \
     "https://app.streamlock.fun/api/cron/reconcile-holdings"
# expect each row to include incremented: <N>

If you want to verify the soft-delete actually fires, induce a temporary RPC outage (or run the cron with a bad RPC URL once) and confirm the leaderboard count does NOT drop. Then run with the good RPC again and confirm it stays at the correct count without flapping.

Follow-ups not in this change

  • Add retryWithBackoff to the four mainnet-tx crons (lst-stake-crank, lst-unstake-crank, accumulator-distribute, timelock-settle-crank). Cosmetic now that soft-delete handles the corruption case, but reduces ops noise.
  • Audit other destructive writes in services/mongodb-api/src/utils/*.ts for the same (compute → deleteMany($nin) → insert) pattern. None spotted on a quick grep, but worth a careful pass.
  • Eventually wrap Connection in a factory that auto-retries getAccountInfo / getMultipleAccountsInfo / getProgramAccounts so future contributors can't forget. Higher effort; deferred.
v0.3.3

LST Staking Backfill — TICKET, DIAMOND-v2 + Trigger Bug Fix

Three of four mainnet pools were launched without LST staking enabled because init_pool defaults staking_enabled = false and the launch flow doesn't auto-call configure_staking. Backfilled the two pools with enough liquid SOL to clear the 0.05 SOL stake floor and fixed a long-standing semantics bug in the JS unstake-crank filter.

Backfill landed (mainnet)

| Pool | Liquid before | Staked now | Status | |---|---|---|---| | TICKET (8exhtA…) | 0.6494 SOL | 0.5195 SOL in JitoSOL | live | | DIAMOND-v2 (9voumCf…) | 1.3174 SOL | 1.0539 SOL in JitoSOL | live | | BUDDHAROID (75r7aq…) | 0.0511 SOL | 0 (in standby) | configured but below 0.05 stake floor | | DIAMOND-v1 (6QdjT4…) | 0.3601 SOL | 1.2483 SOL (unchanged) | already live, accruing yield |

Total mainnet SOL earning JitoSOL yield: ~2.82 SOL, up from 1.25 SOL.

Each pool went through both admin steps: configure_staking (sets lst_mint, max_stake_bps=8000, unstake_price_trigger_bps=8000, staking_enabled=true) and create_lst_vault (creates the JitoSOL ATA owned by sol_vault PDA). Without the second step, the stake-crank skips the pool with lst_vault not configured.

Bug fix — /api/cron/lst-unstake-crank/route.ts:220

The JS cron filter used triggerPrice = target × (10000 - bps) / 10000, which is the inverse of the on-chain check at programs/tokenFactory/src/lib.rs:3091 (trigger_price = target × bps / 10000). With bps = 8000:

  • JS thought trigger fires at 20% of target (way too early)
  • On-chain truth: trigger fires at 80% of target (close to freeze)

The asymmetry was silent for our running pool (DIAMOND-v1) because it stays near max stake ratio, so the price-trigger path is rarely the deciding condition — freeze_active and unlock_window_open were carrying the load. But for actively-trading pools, the cron would mark them eligible too early, submit txs, and on-chain rejected them with UnstakeConditionsNotMet. Wasted gas, no functional impact.

Fix: swap to triggerPrice = target × bps / 10000. Now JS and on-chain agree.

Operational notes

  • BUDDHAROID stays in standby until its liquid SOL grows past ~0.0625 SOL (so headroom clears the 0.05 minimum stake amount). It's correctly configured — the stake-crank just won't act on it yet.
  • Stake-crank fired manually via CRON_SECRET after each backfill rather than waiting for cron-job.org's schedule. SOL was visible in JitoSOL within seconds.
  • Auto-on-launch (option 2 — wiring configure_staking into /api/initialize-token/confirm) is still pending. With three of four pools now empirically healthy, that's the right next step so future launches don't need this manual backfill.

Helper script

scripts/backfill-staking.ts — idempotent one-shot that reads each pool's current state, skips if already configured, and posts both admin calls in sequence. Defaults to BUDDHAROID only (smallest blast radius); --all does all three, --mint <mint> targets one. Used for the backfill above.

v0.3.0

Markets Page: Filter by Vesting Engine

The Markets page now has a Locksmith / Streamflow toggle so you can see at a glance which vesting engine each token uses. This matters because the two engines have different fee structures — and the coming $DIAMOND relaunch runs on Locksmith.

What changed

  • Two-tab filter at the top of /app: Locksmith (default) and Streamflow. No "All" view — every token commits to one engine and you should know which.
  • URL-driven: tab state lives in ?vesting=locksmith|streamflow, so a view is shareable and survives reload.
  • Info tooltip next to the tabs explains the difference in plain English. Tap to open, tap again or anywhere outside to dismiss. Works cleanly on mobile and desktop.
  • Empty state: if the current tab has no tokens on the current chain, the table shows "No {backend} tokens on this chain yet" instead of a blank grid.
  • Loading skeleton refreshed: tabs row is now represented in the skeleton so the layout no longer shifts when data lands; desktop skeleton rebuilt as a real <table> so columns align 1:1 with the loaded state.

Why

Right now a Streamflow token and a Locksmith token look identical in the Markets list, but they behave very differently at settlement — Streamflow charges a 0.19% withdrawal fee plus ~0.176 SOL in stream-creation costs, while Locksmith is zero-fee native escrow. When $DIAMOND relaunches on Locksmith, users need a way to find the zero-fee side of the market without guessing.

Notes

  • Both engines coexist — no token is being deprecated. Existing Streamflow tokens remain fully tradeable; new tokens default to Locksmith.
  • Tokens created before the vestingBackend field was added are treated as Streamflow (historically correct — Locksmith didn't exist yet).
v0.3.1

Stop Locksmith Trades from Draining the Protocol

Every Locksmith buy used to drain the protocol's router_authority wallet by ~0.02 SOL. That wallet pays rent for a few new on-chain accounts created on each trade, and when it ran dry, all Locksmith trading globally stopped with a cryptic Transfer: insufficient lamports error. This release shifts ~78% of that rent off the protocol and onto the buyer who is creating the position.

What changed

In /api/buy-execute, the init_entitlement_ledger instruction now passes the buyer as the rent payer instead of router_authority. Both vesting paths (Locksmith and Streamflow) get the same fix.

The buyer was already a tx signer, so this adds no extra wallet prompt. It just reroutes ~0.0157 SOL of per-buy rent from the protocol's pocket to the buyer's pocket — where it should have been all along.

Why this matters

  • Before: router_authority shouldered ~0.02 SOL of rent per trade. The wallet drained mono-directionally; trading bricked the moment it hit empty.
  • After: router_authority only covers ~0.0045 SOL of residual rent on create_price_lock_stream (the part that's coupled to the on-chain token-source authority and can't be moved off without a program upgrade — that's Phase 2).
  • Net result: trading no longer depends on a babysitter cron topping up router_authority. The protocol scales with usage instead of being throttled by it.

What buyers will notice

A given Locksmith buy now requires roughly 0.0157 SOL more in the buyer's wallet than before. In practice this is invisible for any normal-sized buy — the swap amount itself is usually orders of magnitude bigger. Marginal-balance buyers might see a wallet simulation error in the rare case they were within ~0.02 SOL of the floor.

Notes

  • excess-stream-execute (the server-side route that creates 1%-cap-overflow streams) is unchanged — the buyer doesn't sign that tx, so router_authority correctly remains the payer there.
  • This is Phase 1 of a two-phase fix. Phase 2 — an on-chain change to locksmith_master::create_price_lock_stream that decouples its rent payer from the token-source authority — eliminates the remaining 0.0045 SOL/buy drain. Phase 2 is not urgent; this release ends the operational fire on its own.
v0.3.2

Phase 2 Frontend Wiring — Locksmith Rent-Payer Decouple (Dormant)

This release lands the frontend half of Phase 2: the off-chain wiring needed to call create_price_lock_stream_v2 once the on-chain locksmith program ships. The new code path is dormant by default — until LOCKSMITH_USE_V2=true is set, every Locksmith buy still uses v1 and behaves exactly like v0.3.1. No user-visible change.

What's new (gated behind LOCKSMITH_USE_V2)

  • buildCreatePriceLockInstructionV2 and buildCreateTimeLockInstructionV2 helpers in src/utils/stream/locksmithStreamHelper.ts accept an explicit payer slot. Discriminators populated from the freshly synced src/idl/locksmith_master.json.
  • /api/buy-execute now dispatches to v2 when LOCKSMITH_USE_V2=true, passing payer: buyer. v1 stays as the default fallback.
  • /api/excess-stream-execute deliberately stays on v1 forever — that route is server-signed (router_authority is the only signer on the tx), so there's no buyer signature available to fund rent. Comment in the source explains why.

What this completes when the flag flips

Once the on-chain locksmith_master v2 instructions are deployed to mainnet and LOCKSMITH_USE_V2=true is flipped in Vercel prod env, every Locksmith buy will:

  • Pay 0 SOL of rent from router_authority (was ~0.0045 SOL after v0.3.1; ~0.0202 SOL pre-v0.3.1)
  • Pay an additional ~0.0045 SOL of rent from the buyer's wallet (combined with the ~0.0157 SOL already paid for the entitlement-ledger PDAs)
  • Make router_authority operationally hands-off — no more babysitting required for trading to keep working

Rollout sequence (recap)

  1. v0.3.2 ships now — frontend ready, flag off
  2. streamlock-blockchain agent deploys to devnetanchor deploy --provider.cluster devnet -p locksmith_master
  3. Devnet smoke test with LOCKSMITH_USE_V2=true in a Vercel preview env
  4. Mainnet on-chain upgrade if smoke passes
  5. Flip LOCKSMITH_USE_V2=true in Vercel prod env — single env-var toggle, no redeploy

If anything goes wrong post-flip, flipping back to false instantly returns to v1 behavior. No code rollback needed.

Notes

  • Pre-existing scaffolding from a prior session already had the v2 builders + the env-flag dispatch in place. This release fills in the discriminators ([225, 176, 137, 9, 173, 214, 62, 53] for price-lock-v2, [165, 89, 210, 221, 175, 39, 179, 116] for time-lock-v2) and syncs the new IDL from the streamlock-blockchain repo.
  • v1 builders, account orderings, and discriminators are untouched. Existing in-flight transactions and any client running an older build continue to work after this release.
  • buyValidation.ts is intentionally not modified — it doesn't have a min-SOL preflight to bump. Any UX polish for buyers near the SOL floor is a separate task.
v0.2.7

Locksmith Time-Lock Settlement — Over-1% Excess Now Claimable

Holders who've bought more than 1% of a locksmith-backed token in a single wallet can now claim the SOL value of their over-cap excess as it vests. Previously those tokens were trapped in on-chain vaults with no settlement path; now they drip out over 67 days of linear vesting after a 67-day cliff, claimable against the existing proceeds ledger.

What changed

  • Permissionless time-lock withdraw on the locksmith primitive (locksmith_master::withdraw_time_locked now accepts a non-signing recipient), making the recipient-is-PDA pattern work end-to-end. Same safety model as the existing price-lock path: has_one + token::authority + on-chain vest math are the real gates.
  • New router instruction streamlock_router::settle_time_locked_stream — the symmetric wrapper for time-lock streams. Incremental settlement semantics: every call after cliff_ts pulls whatever's newly vested, sells through the bonding curve during the pool's next UNLOCK window, and accumulates SOL into the proceeds vault. ledger.settled flips true only once the locksmith vault is fully drained.
  • Relaxed claim gate on streamlock_router::claim_proceeds — now gates on proceeds_total > 0 rather than settled == true. Price-lock claims behave identically (both fields are set in the same transaction); time-lock claims now release each incremental drip immediately via the existing per-claimant claimed_lamports bookkeeping, no waiting for full vest.
  • Cron drip crank (GET /api/cron/timelock-settle-crank) — phase-aware sweep that pays gas for holders' subsequent drips once they've done an initial settle. Respects the pool's UNLOCK window and skips streams with no active proceeds to move.
  • Frontend routes/api/settle-stream now dispatches between price-lock and time-lock stream kinds automatically; /api/stream-status returns the correct currently-vested amount (cliff+linear math), replacing a fallback that claimed 100% vested from day 1.

What's next

A dedicated drip UI in the Sell panel (cliff countdown, vest progress bar, running proceeds tally) and retirement of the legacy Streamflow fallback for settle-time excess will land in a follow-up release.

For curious readers

A deployed devnet smoke test (scripts/tests/015_test_locksmith_timelock_settle.ts) exercises the full chain — pre-cliff revert, mid-vest drip settle + claim ×2, post-end final drain, and the StreamAlreadySettled re-entry guard — with a short-cliff test pool and runs in ~3 minutes. Mainnet on-chain deploy at HGnDvnxw… (locksmith) and 2atKseCg… (router).

v0.2.8

Milestone-Anchored Cliff for Anti-Whale Excess

The 67-day cliff on anti-whale excess tokens is now anchored to each token's first price milestone, not just the buy time. Whales who accumulate >1% of a token at launch can no longer wait out a fixed wall-clock timer regardless of whether the token performs — the excess stays locked until the token hits its first milestone and 67 days pass after that hit.

What changed

  • effective_cliff = max(stream.cliff_ts, first_milestone_hit_ts + 67d) is the new on-chain gate in streamlock_router::settle_time_locked_stream. Pre-milestone pools reject settle attempts with a new MilestoneNotReached error.
  • PoolConfig gets a new first_milestone_hit_ts: i64 field, stamped once when a pool's first freeze fires, preserved across subsequent milestones. Existing pools were migrated via migrate_pool_config as part of the deploy.
  • The position UI shows three states on a locksmith time-lock stream:
    • Disarmed (amber): "Token hasn't hit its first price milestone yet."
    • Pre-effective-cliff (zinc): counts down to the effective cliff date.
    • Claimable (purple): normal drip panel with Claim button.
  • Cancelled streams remain drainable regardless of milestone state. If a creator cancels, the recipient can always recover their vault residual.

Who this affects

No existing holder. Zero time-lock streams currently exist on mainnet; the upgrade landed before any user's cliff became imminent. Going forward, every buy that trips the 1% cap creates a time-lock that's bound by the new gate.

Why

The old design (cliff anchored to buy time) allowed a fast-pump-and-dump whale to claim their 1% price-gated position at M1's UNLOCK window, dump, and then wait out a fixed 67-day timer to claim their excess. Anchoring to milestone-hit means whales can't lock in timing guarantees independent of the token's performance.

Under the new design:

  • Fast pump (M1 at Day 30): excess unlocks at Day 30 + 67 = Day 97, not Day 67.
  • Slow build (M1 at Day 90): excess unlocks at Day 90 + 67 = Day 157.
  • Token never hits a milestone: excess stays locked (same as before — settle_sale_from_vault always required UNLOCK, so this case was already stuck).

Deploy details

  • token_factory mainnet upgraded — new first_milestone_hit_ts field at account offset 401. All 7 existing mainnet PoolConfig accounts migrated via migrate_pool_config. Account size 401 → 409 bytes.
  • streamlock_router mainnet upgraded — new effective-cliff math, new MilestoneNotReached error code.
  • Program IDs unchanged (6TviBvKx… / 2atKseCg…).
v0.2.6

LP Provision Live on Mainnet

The LP Provision + Fee Splitter system is now live on Solana mainnet-beta. Every new token launch auto-provisions permanent, rug-proof LP for the creator, and trading fees now flow to LP providers as well as the existing holders, creator, and protocol splits.

What this unlocks

  • Creators earn an LP share on top of the 10% creator royalty. Every trade on your token pays a share to anyone (including you) providing liquidity to the pool.
  • Anyone can add LP and earn a pro-rata slice of trading fees, Synthetix-style. LP contributions are permanently locked — no LP rug, no short-term mercenary liquidity.
  • Token pools get permanent on-chain liquidity that compounds as the token trades. No more "where did the liquidity go" after launch day.

Fee distribution

The protocol's portion of trading fees now splits further: 60% of it flows to LP providers, 40% to protocol treasury. Per-trade overall:

  • 40% to token holders (Merkle claims, unchanged)
  • 10% to token creator (direct, unchanged)
  • 30% to LP providers (new)
  • 20% to protocol treasury (was 50%)

New user-facing pages

  • /[chain]/[tokenAddress]/liquidity — pool stats, your LP position, and controls to inject or claim fees.
  • Inline LP provision callout on every trading page.

Under the hood

  • token_factory upgraded in-place to V2b at 6TviBvKxg8jGhJaKfSKsD8Ph93cxrKFUrkxpi9peTcT5.
  • Existing pools continue trading unchanged — V2b is purely additive. Regression-tested with a real buy on an existing pre-V2b pool: no size change, no state corruption, no reverts.
  • Fee-splitter math uses u128 intermediates with 1e18 precision, matching the battle-tested Synthetix reward-per-share pattern.

What's next

The Operator API + SDK is next — letting third-party game developers build on top of every Streamlock stream's entitlement ledger.

v0.2.4

Mainnet Locksmith Deploy

Native locksmith_master vesting program is live on mainnet-beta. The streamlock router was upgraded in-place to add settle_stream_locksmith and point CPI targets at the new mainnet locksmith address.

Mainnet program IDs

  • locksmith_master: HGnDvnxwuvtcJ32CqZf1AFPUUqz3QUSwRsPFyH1FqRA5 (fresh deploy)
  • streamlock_router: 2atKseCgCRxwPnne67FwwDdTzdx7L3PveyHhzR6NgzmL (upgrade, same address)
  • token_factory: 6TviBvKxg8jGhJaKfSKsD8Ph93cxrKFUrkxpi9peTcT5 (unchanged)

Frontend changes

  • src/idl/locksmith_master.json + src/idl/streamlock_router.json refreshed from the mainnet build. New router IDL includes the settle_stream_locksmith instruction.
  • NEXT_PUBLIC_LOCKSMITH_PROGRAM_ID added to production env vars.
  • PROGRAM_IDS_REFERENCE_BOOK.md is now the single source of truth for program addresses across clusters.

Compatibility

Existing mainnet pools remain tradable without change:

  • token_factory is unchanged — every existing PoolConfig, PriceLockStream, and entitlement ledger is untouched.
  • The router upgrade is additive — only a new instruction + one new const. Existing instruction discriminators and account layouts are preserved.
  • Pre-locksmith streams route through the streamflow path as before, either via their persisted vestingBackend='streamflow' (backfilled by the v35 migration on mainnet_v0) or the base58-vs-hex format-fallback guard.

The deployed router binary was independently verified against the build artifact to confirm its compile-time constants point at the correct mainnet program IDs.

v0.2.3

Admin Duration Flips + Scenario D Exercised

Extends /api/admin/update-fees to support duration + authority updates, and exercises stress-test scenario D (sell-fee decay) end-to-end for the first time using a test pool with a short unlock window.

New

  • /api/admin/update-fees (POST) — now accepts freezeBaseDurationSec, unlockBaseDurationSec, and newAuthority in addition to the existing fee fields. Enables temporary duration flips for test scenarios without a program redeploy.
  • /api/admin/update-fees (GET) — reads and returns all TokenFactoryConfig fields. Lets callers snapshot current values before a flip so the restore targets the exact previous state.

Fixes

  • Latent bug in /api/admin/update-fees POST: the instruction args array was missing the 27th arg (new_authority: Option<Pubkey>), causing InstructionDidNotDeserialize (Anchor error 102) on any attempted write. Only surfaced now because no one had exercised the route for duration changes; fee-only updates happened to deserialize anyway when the program later tightened arg count. Added encodeOptionPubkey helper + final arg.

Scenario D results

On a fresh devnet pool with unlockBaseDurationSec temporarily flipped to 180s, test 013's D scenario ran end-to-end for the first time. Fee-curve samples at 5%/25%/50%/75% through the unlock window produced expected BPS values matching the calculate_sell_fee formula 2500 − 2400 × elapsed/duration exactly. All 4 settles landed on-chain; no overflow or rounding drift.

Known limitation: the test's "observed BPS" cross-check is algebraically circular (derives observed from solReceived / (1 − expectedBps) → always returns expectedBps). For audit-grade signal, future work should compute expected sol_out from pre-settle pool reserves (x·y=k) and compare to actual sol_out. The formula values themselves were validated at 4 distinct time points.

Devnet txs

  • Config flip (freeze=30s, unlock=180s): 5wF1pxcMkeVZhuwkmDhzwRYm92bsADZC3D2cBbB6kJqpiyryFHWp8cSnPhPZief3Czuk7bmTjFdDroVSL9kHfFHd
  • Config restore (freeze=86400s, unlock=172800s): zY5uu8rFHGQh8rS9rSj9vjwwxzaqD1i6msxW8QM1DgeGiWr3nReSxU8taEWnRo3tEDxriURhaTPXjva6VFmxRmJ

Config verified restored to production values post-test.

v0.2.1

Locksmith Hotfixes — Programmatic E2E + Mainnet Audit

Follow-up to yesterday's locksmith E2E release. Adds a programmatic integration test, the LaunchToken creator picker, and fixes five subtle bugs found during test authoring.

New

  • scripts/tests/012_test_locksmith_e2e.ts — full buy → freeze → unlock → settle → claim cycle test. Runs against the HTTP API with a devnet keypair. Loop mode (LOOP_INTERVAL=3600) makes it the soak monitor. Supports STREAM_ID=<hex> to skip the buy step and settle an existing stream (useful when the buyer is at the 1% holding cap).
  • LaunchToken vesting backend picker — Step 2 of the launch wizard now exposes a Locksmith / Streamflow toggle. Threads vestingBackend to both /api/initialize-token and /confirm. Default stays Locksmith.

Fixes

  • 🚨 tf_router_sol_vault_tokens_ata not initialized on fresh pools — locksmith settle errored with AccountNotInitialized (Anchor 3012). Only masked on the manual walkthrough because the test pool had prior Streamflow activity that created the ATA as a side effect. Both /api/settle-stream and /api/close-position now auto-create the ATA when missing. Would've broken the first-ever mainnet locksmith settle.
  • ExecuteResponse type now includes vestingBackend: "locksmith" | "streamflow" (was only in runtime shape).

Devnet signatures

  • Integration test settle (cycle 3): 4D5RQw3hqN6SEs8Lwip85Humz1snD612JpB8d8KzuDmBT3z998JsbxZtERG2BBEaqYZ2Uy5QgT3AishtHWWoA3hF
  • Integration test claim (cycle 3): 2wzEmVeCjAUUGEB74Tvfi5kcfieabRa2zyoFx2t9PpHJkCjJjAhfFHyMSq7dHn9D6vSocDWg6moBdRm9aqHSbwK1

Docs

FRONTIER_HACKATHON.md §3 now has a full mainnet-readiness audit covering IDL addresses, program ID env vars, $LOCK mint, LST addresses, Streamflow ID, chain detection, and the services/mongodb-api default. Bottom line: six gates remain before mainnet promotion, none blocking on code correctness.

v0.2.2

Locksmith Unlock-Window Stress Test

Adds a programmatic stress test (scripts/tests/013_test_locksmith_stress.ts) that fires concurrent buys/sells from N fresh keypairs to exercise audit-relevant boundary behaviors.

What it tests

  • A. Burst buys — N wallets × 2 buys in parallel. Validates concurrent /api/buy-execute correctness, ATA init, PDA seeds under load.
  • B. Force UNLOCK — reproduces the two-phase force-freeze pattern; skips if pool already in unlock.
  • C. Epoch cap backoff — tight sellCap + concurrent settles; asserts some hit the cap, others succeed, and retries pass after epoch rollover. No corruption errors allowed.
  • D. Sell-fee decay — samples settles across unlock window boundaries. Skipped when window >3600s (requires test pool with short unlock_base_duration_sec).
  • E. Concurrent claim race — 5× parallel claim_proceeds on same (stream, claimant). Asserts no double-spend by measuring vault delta ≤ proceedsTotal, not by counting API-reported successes (send route's "already processed" heuristic inflates the count harmlessly).
  • F. Rent-floor protection — drains proceeds_vault; verifies last claim is capped at vault_balance − rent_floor.

Results (N=10, devnet)

All 5 runnable scenarios pass in 156s. D correctly skipped. Zero unexpected errors. See stress-report-1776451342085.json.

  • 20/20 concurrent buys, p99 967ms
  • Cap backoff: 1 ok + 9 cap-hits first epoch, 5/9 retries recover
  • Claim race: vault Δ=631,984 ≤ proceedsTotal=638,368 — on-chain claim_state guards correctly
  • Rent floor: vault preserved at 897,246 ≥ 890,880 (rent-exempt minimum)

Known gap

Scenario D is parked. Exercising it meaningfully requires a freshly-launched devnet pool configured with a short unlock window (≤5min). The existing test pool has the production 48h window, which makes fee-decay sampling indistinguishable from the curve's starting value.

v0.2.0

Locksmith Vesting Layer — E2E on Devnet

Native locksmith_master vesting layer is now wired end-to-end and verified on devnet. Primary goal of Frontier deliverable #3 reached: a locksmith-backed pool can be launched, traded, settled, and claimed without touching Streamflow.

New

  • locksmith_master program — native Anchor escrow for price-gated and time-based vesting streams. 25 unit tests. Deployed to devnet at EiWkxffWGAsTJxgaYQnAyVvivygEzzoxvUi3BWyFWuQT.
  • streamlock_router::settle_stream_locksmith — dedicated router instruction that CPIs withdraw_price_locked (locksmith) then settle_sale_from_vault (token_factory). Existing settle_stream (Streamflow) path untouched. Grandfathered pools keep running on Streamflow forever.
  • Dispatch wiring across 4 hot paths/api/stream-status, /api/settle-stream, /api/close-position, and userHoldingsHelper.ts now route on vestingBackend. Default for new pools is locksmith; Streamflow is opt-in fallback.
  • Defensive format fallback — when vestingBackend is missing or null, routes infer from metadataId format (64-char hex = locksmith, base58 = streamflow). Prevents legacy-row bugs from bricking the dispatch.
  • services/mongodb-api vestingBackend persistence — the save route now accepts and stores the field. One-shot release_command migration backfills legacy rows as streamflow on deploy.

LP follow-ups (shipped 2026-04-12 → 04-13)

  • Auto-LP on token launch — creator's initial SOL provisions LP in the launch transaction. Creator earns 10% royalty + pro-rata LP fee share.
  • Auto fee-splitter init — 60/40 LP/protocol split configured server-side on confirm.
  • Zap-in removed — LP injection is direct-only. Dual-sided LP is mathematically impossible in a bonding curve, so zap-in was 4–13% fee for zero additional benefit.
  • Price pipeline fix/api/price/invalidate now re-populates the cache instead of just marking it stale. Post-trade price shows in ~200ms instead of falling through to live RPC (~1.8s).
  • Claim double-click fixLpFeeClaimPanel optimistically zeroes userPendingLamports after claim to prevent users from wasting gas on no-op retries while RPC propagates.

Fixes

  • /api/set-target-price was missing the optional token_factory_config account → AccountNotEnoughKeys on every call. Now derives the PDA explicitly.
  • buildSettleStreamLocksmithInstruction added tokenFactoryProgramId as a remaining account so the router's CPI to settle_sale_from_vault resolves the program lookup.
  • BuyComponent now forwards vestingBackend to /api/user-streams/save (was silently dropped, causing null persistence).
  • SellComponent detects hex-format metadataId across all 9 call sites instead of assuming base58 (was throwing Non-base58 character).
  • Close-position button stays visible post-refresh when the ledger is settled but the claim step is still pending.

Verified on devnet

  • Settle: xiJkNYw2RKipPwUbNutLXJFY4Qrnd27HxNFooa3iQbMux3FmKKwuFRWbBZPwZrNkM3Ahy2HtUKz1bfeUnQLhoXy
  • Claim: MWm7mL9Q3qakKdWPrBYr41xUu2ytdhCnN4GVjH8VXgqxHto1Sqgn49oGbpJPfWcnP5LBrLarAUVBewp9hsWDaR5
  • LP injection + LP fee claim regression-checked on the same pool — both work unchanged.
v0.1.0

Streamlock Public Launch

We're live on Solana mainnet. Here's what shipped in our initial release:

  • Stream-locked token launches — every buy is atomically locked in a Streamflow escrow contract
  • Milestone-based unlock cycles — GRIND, FREEZE, UNLOCK, repeat with progressively higher targets
  • Portfolio tracking — view all your locked streams, entitlements, and positions in one place
  • Referral system — invite friends, earn rewards, and climb tiers
  • LST yield — earn JitoSOL staking yield on locked liquidity
  • Mobile-first UI — fully responsive with bottom nav and swipe drawer